From 06d32a8a894480673dce31241d81f7aeee50d04e Mon Sep 17 00:00:00 2001
From: David Taylor <david@taylorhq.com>
Date: Wed, 16 Oct 2024 11:15:19 +0100
Subject: [PATCH] DEV: Refactor uppy component mixins into standalone classes
 (#28710)

This commit replaces all uppy-related mixins with standalone classes. The main entrypoint is now lib/uppy/uppy-upload.js, which has a list of its config options listed at the top of the file. Functionality & logic is completely unchanged.

The uppy-upload mixin is replaced with a backwards-compatibility shim, which will allow us to migrate to the new pattern incrementally.
---
 .../app/components/composer-editor.js         |  50 +-
 .../app/components/uppy-image-uploader.js     |   9 -
 .../uppy/composer-upload.js}                  | 435 ++++++------
 .../uppy/s3-multipart.js}                     |  71 +-
 .../{mixins => lib/uppy}/upload-debugging.js  |  95 +--
 .../discourse/app/lib/uppy/uppy-upload.js     | 549 ++++++++++++++++
 .../uppy/wrapper.js}                          | 112 ++--
 .../mixins/composer-video-thumbnail-uppy.js   |  28 +-
 .../discourse/app/mixins/uppy-upload.js       | 618 ++++--------------
 .../discourse/app/services/composer.js        |   4 +-
 .../components/chat-composer-uploads.js       |   6 +-
 11 files changed, 1077 insertions(+), 900 deletions(-)
 rename app/assets/javascripts/discourse/app/{mixins/composer-upload-uppy.js => lib/uppy/composer-upload.js} (63%)
 rename app/assets/javascripts/discourse/app/{mixins/uppy-s3-multipart.js => lib/uppy/s3-multipart.js} (80%)
 rename app/assets/javascripts/discourse/app/{mixins => lib/uppy}/upload-debugging.js (57%)
 create mode 100644 app/assets/javascripts/discourse/app/lib/uppy/uppy-upload.js
 rename app/assets/javascripts/discourse/app/{mixins/extendable-uploader.js => lib/uppy/wrapper.js} (62%)

diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js
index 46a27ed3335..5aaf5b55ac4 100644
--- a/app/assets/javascripts/discourse/app/components/composer-editor.js
+++ b/app/assets/javascripts/discourse/app/components/composer-editor.js
@@ -20,6 +20,7 @@ import {
 import { loadOneboxes } from "discourse/lib/load-oneboxes";
 import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
 import { authorizesOneOrMoreImageExtensions } from "discourse/lib/uploads";
+import UppyComposerUpload from "discourse/lib/uppy/composer-upload";
 import userSearch from "discourse/lib/user-search";
 import {
   destroyUserStatuses,
@@ -31,7 +32,6 @@ import {
   formatUsername,
   inCodeBlock,
 } from "discourse/lib/utilities";
-import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
 import Composer from "discourse/models/composer";
 import { isTesting } from "discourse-common/config/environment";
 import { tinyAvatar } from "discourse-common/lib/avatar-utils";
@@ -110,23 +110,11 @@ const DEBOUNCE_FETCH_MS = 450;
 const DEBOUNCE_JIT_MS = 2000;
 
 @classNameBindings("showToolbar:toolbar-visible", ":wmd-controls")
-export default class ComposerEditor extends Component.extend(
-  ComposerUploadUppy
-) {
-  editorClass = ".d-editor";
-  fileUploadElementId = "file-uploader";
-  mobileFileUploaderId = "mobile-file-upload";
+export default class ComposerEditor extends Component {
   composerEventPrefix = "composer";
-  uploadType = "composer";
-  uppyId = "composer-editor-uppy";
-  composerModelContentKey = "reply";
-  editorInputClass = ".d-editor-input";
   shouldBuildScrollMap = true;
   scrollMap = null;
   processPreview = true;
-  uploadMarkdownResolvers = uploadMarkdownResolvers;
-  uploadPreProcessors = uploadPreProcessors;
-  uploadHandlers = uploadHandlers;
 
   @alias("composer") composerModel;
 
@@ -134,6 +122,14 @@ export default class ComposerEditor extends Component.extend(
     super.init(...arguments);
     this.warnedCannotSeeMentions = [];
     this.warnedGroupMentions = [];
+
+    this.uppyComposerUpload = new UppyComposerUpload(getOwner(this), {
+      composerEventPrefix: this.composerEventPrefix,
+      composerModel: this.composerModel,
+      uploadMarkdownResolvers,
+      uploadPreProcessors,
+      uploadHandlers,
+    });
   }
 
   @discourseComputed("composer.requiredCategoryMissing")
@@ -261,8 +257,7 @@ export default class ComposerEditor extends Component.extend(
     }
 
     if (this.allowUpload) {
-      this._bindUploadTarget();
-      this._bindMobileUploadButton();
+      this.uppyComposerUpload.setup(this.element);
     }
 
     this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
@@ -840,8 +835,7 @@ export default class ComposerEditor extends Component.extend(
     const preview = this.element.querySelector(".d-editor-preview-wrapper");
 
     if (this.allowUpload) {
-      this._unbindUploadTarget();
-      this._unbindMobileUploadButton();
+      this.uppyComposerUpload.teardown();
     }
 
     this.appEvents.trigger(`${this.composerEventPrefix}:will-close`);
@@ -907,26 +901,6 @@ export default class ComposerEditor extends Component.extend(
     return element.tagName === "ASIDE" && element.classList.contains("quote");
   }
 
-  _cursorIsOnEmptyLine() {
-    const textArea = this.element.querySelector(".d-editor-input");
-    const selectionStart = textArea.selectionStart;
-    if (selectionStart === 0) {
-      return true;
-    } else if (textArea.value.charAt(selectionStart - 1) === "\n") {
-      return true;
-    } else {
-      return false;
-    }
-  }
-
-  _findMatchingUploadHandler(fileName) {
-    return this.uploadHandlers.find((handler) => {
-      const ext = handler.extensions.join("|");
-      const regex = new RegExp(`\\.(${ext})$`, "i");
-      return regex.test(fileName);
-    });
-  }
-
   @action
   extraButtons(toolbar) {
     toolbar.addButton({
diff --git a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
index d77ea75f3be..95b09c54f70 100644
--- a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
+++ b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
@@ -85,12 +85,6 @@ export default class UppyImageUploader extends Component.extend(
     return { imagesOnly: true };
   }
 
-  _uppyReady() {
-    this._onPreProcessComplete(() => {
-      this.set("processing", false);
-    });
-  }
-
   uploadDone(upload) {
     this.setProperties({
       imageFilesize: upload.human_filesize,
@@ -139,9 +133,6 @@ export default class UppyImageUploader extends Component.extend(
 
   @action
   trash() {
-    // uppy needs to be reset to allow for more uploads
-    this._reset();
-
     // the value of the property used for imageUrl should be cleared
     // in this callback. this should be done in cases where imageUrl
     // is bound to a computed property of the parent component.
diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js b/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js
similarity index 63%
rename from app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js
rename to app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js
index 9d11cdede07..3d984a66ff0 100644
--- a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js
+++ b/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js
@@ -1,7 +1,6 @@
 import { warn } from "@ember/debug";
 import EmberObject from "@ember/object";
-import Mixin from "@ember/object/mixin";
-import { getOwner } from "@ember/owner";
+import { getOwner, setOwner } from "@ember/owner";
 import { run } from "@ember/runloop";
 import { service } from "@ember/service";
 import Uppy from "@uppy/core";
@@ -16,130 +15,156 @@ import {
   getUploadMarkdown,
   validateUploadedFile,
 } from "discourse/lib/uploads";
+import UppyS3Multipart from "discourse/lib/uppy/s3-multipart";
+import UppyWrapper from "discourse/lib/uppy/wrapper";
 import UppyChecksum from "discourse/lib/uppy-checksum-plugin";
 import { clipboardHelpers } from "discourse/lib/utilities";
 import ComposerVideoThumbnailUppy from "discourse/mixins/composer-video-thumbnail-uppy";
-import ExtendableUploader from "discourse/mixins/extendable-uploader";
-import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart";
 import getURL from "discourse-common/lib/get-url";
-import { deepMerge } from "discourse-common/lib/object";
-import { bind, observes, on } from "discourse-common/utils/decorators";
+import { bind } from "discourse-common/utils/decorators";
 import escapeRegExp from "discourse-common/utils/escape-regexp";
 import I18n from "discourse-i18n";
 
-// Note: This mixin is used _in addition_ to the ComposerUpload mixin
-// on the composer-editor component. It overrides some, but not all,
-// functions created by ComposerUpload. Eventually this will supplant
-// ComposerUpload, but until then only the functions that need to be
-// overridden to use uppy will be overridden, so as to not go out of
-// sync with the main ComposerUpload functionality by copying unchanging
-// functions.
-//
-// Some examples are uploadPlaceholder, the main properties e.g. uploadProgress,
-// and the most important _bindUploadTarget which handles all the main upload
-// functionality and event binding.
-//
-export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
-  dialog: service(),
-  session: service(),
+export default class UppyComposerUpload {
+  @service dialog;
+  @service session;
+  @service siteSettings;
+  @service appEvents;
+  @service currentUser;
+  @service site;
+  @service capabilities;
+  @service messageBus;
+  @service composer;
 
-  uploadRootPath: "/uploads",
-  uploadTargetBound: false,
-  useUploadPlaceholders: true,
+  uppyWrapper;
+
+  uploadRootPath = "/uploads";
+  uppyId = "composer-editor-uppy";
+  uploadType = "composer";
+  editorInputClass = ".d-editor-input";
+  mobileFileUploaderId = "mobile-file-upload";
+  fileUploadElementId = "file-uploader";
+  editorClass = ".d-editor";
+
+  composerEventPrefix;
+  composerModel;
+  uploadMarkdownResolvers;
+  uploadPreProcessors;
+  uploadHandlers;
+
+  #inProgressUploads = [];
+  #bufferedUploadErrors = [];
+  #placeholders = {};
+
+  #useUploadPlaceholders = true;
+  #uploadTargetBound = false;
+  #userCancelled = false;
+
+  #fileInputEl;
+  #editorEl;
+
+  constructor(
+    owner,
+    {
+      composerEventPrefix,
+      composerModel,
+      uploadMarkdownResolvers,
+      uploadPreProcessors,
+      uploadHandlers,
+    }
+  ) {
+    setOwner(this, owner);
+    this.uppyWrapper = new UppyWrapper(owner);
+    this.composerEventPrefix = composerEventPrefix;
+    this.composerModel = composerModel;
+    this.uploadMarkdownResolvers = uploadMarkdownResolvers;
+    this.uploadPreProcessors = uploadPreProcessors;
+    this.uploadHandlers = uploadHandlers;
+  }
 
   @bind
-  _cancelSingleUpload(data) {
-    this._uppyInstance.removeFile(data.fileId);
-  },
-
-  @observes("composerModel.uploadCancelled")
-  _cancelUpload() {
-    if (!this.get("composerModel.uploadCancelled")) {
-      return;
+  _cancelUpload(data) {
+    if (data) {
+      // Single file
+      this.uppyWrapper.uppyInstance.removeFile(data.fileId);
+    } else {
+      // All files
+      this.#userCancelled = true;
+      this.uppyWrapper.uppyInstance.cancelAll();
     }
-    this.set("composerModel.uploadCancelled", false);
-    this.set("userCancelled", true);
+  }
 
-    this._uppyInstance.cancelAll();
-  },
-
-  @on("willDestroyElement")
-  _unbindUploadTarget() {
-    if (!this.uploadTargetBound) {
+  teardown() {
+    if (!this.#uploadTargetBound) {
       return;
     }
 
-    this.fileInputEl?.removeEventListener(
+    this.#fileInputEl?.removeEventListener(
       "change",
       this.fileInputEventListener
     );
 
-    this.editorEl?.removeEventListener("paste", this.pasteEventListener);
+    this.#editorEl?.removeEventListener("paste", this._pasteEventListener);
 
     this.appEvents.off(`${this.composerEventPrefix}:add-files`, this._addFiles);
     this.appEvents.off(
       `${this.composerEventPrefix}:cancel-upload`,
-      this._cancelSingleUpload
+      this._cancelUpload
     );
 
-    this._reset();
+    this.#reset();
 
-    if (this._uppyInstance) {
-      this._uppyInstance.close();
-      this._uppyInstance = null;
+    if (this.uppyWrapper.uppyInstance) {
+      this.uppyWrapper.uppyInstance.close();
+      this.uppyWrapper.uppyInstance = null;
     }
 
-    this.uploadTargetBound = false;
-  },
+    this.#unbindMobileUploadButton();
+    this.#uploadTargetBound = false;
+  }
 
-  _abortAndReset() {
+  #abortAndReset() {
     this.appEvents.trigger(`${this.composerEventPrefix}:uploads-aborted`);
-    this._reset();
+    this.#reset();
     return false;
-  },
+  }
 
-  _bindUploadTarget() {
-    this.set("inProgressUploads", []);
-    this.set("bufferedUploadErrors", []);
-    this.placeholders = {};
-    this._preProcessorStatus = {};
-    this.editorEl = this.element.querySelector(this.editorClass);
-    this.fileInputEl = document.getElementById(this.fileUploadElementId);
-    const isPrivateMessage = this.get("composerModel.privateMessage");
+  setup(element) {
+    this.#editorEl = element.querySelector(this.editorClass);
+    this.#fileInputEl = document.getElementById(this.fileUploadElementId);
 
     this.appEvents.on(`${this.composerEventPrefix}:add-files`, this._addFiles);
     this.appEvents.on(
       `${this.composerEventPrefix}:cancel-upload`,
-      this._cancelSingleUpload
+      this._cancelUpload
     );
 
-    this._unbindUploadTarget();
     this.fileInputEventListener = bindFileInputChangeListener(
-      this.fileInputEl,
+      this.#fileInputEl,
       this._addFiles
     );
-    this.editorEl.addEventListener("paste", this.pasteEventListener);
+    this.#editorEl.addEventListener("paste", this._pasteEventListener);
 
-    this._uppyInstance = new Uppy({
+    this.uppyWrapper.uppyInstance = new Uppy({
       id: this.uppyId,
       autoProceed: true,
 
       // need to use upload_type because uppy overrides type with the
       // actual file type
-      meta: deepMerge({ upload_type: this.uploadType }, this.data || {}),
+      meta: { upload_type: this.uploadType },
 
       onBeforeFileAdded: (currentFile) => {
         const validationOpts = {
           user: this.currentUser,
           siteSettings: this.siteSettings,
-          isPrivateMessage,
+          isPrivateMessage: this.composerModel.privateMessage,
           allowStaffToUploadAnyFileInPm:
             this.siteSettings.allow_staff_to_upload_any_file_in_pm,
         };
 
         const isUploading = validateUploadedFile(currentFile, validationOpts);
 
-        this.setProperties({
+        this.composer.setProperties({
           uploadProgress: 0,
           isUploading,
           isCancellable: isUploading,
@@ -162,7 +187,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
         const handlerBuckets = {};
 
         for (const [fileId, file] of Object.entries(files)) {
-          const matchingHandler = this._findMatchingUploadHandler(file.name);
+          const matchingHandler = this.#findMatchingUploadHandler(file.name);
           if (matchingHandler) {
             // the function signature will be converted to a string for the
             // object key, so we can send multiple files at once to each handler
@@ -186,7 +211,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
         // a single file at a time through to the handler.
         for (const bucket of Object.values(handlerBuckets)) {
           if (!bucket.fn(bucket.files, this)) {
-            return this._abortAndReset();
+            return this.#abortAndReset();
           }
         }
 
@@ -199,7 +224,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
               count: maxFiles,
             })
           );
-          return this._abortAndReset();
+          return this.#abortAndReset();
         }
 
         // uppy uses this new object to track progress of remaining files
@@ -208,34 +233,40 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
     });
 
     if (this.siteSettings.enable_upload_debug_mode) {
-      this._instrumentUploadTimings();
+      this.uppyWrapper.debug.instrumentUploadTimings(
+        this.uppyWrapper.uppyInstance
+      );
     }
 
     if (this.siteSettings.enable_direct_s3_uploads) {
-      this._useS3MultipartUploads();
+      new UppyS3Multipart(getOwner(this), {
+        uploadRootPath: this.uploadRootPath,
+        uppyWrapper: this.uppyWrapper,
+        errorHandler: this._handleUploadError,
+      }).apply(this.uppyWrapper.uppyInstance);
     } else {
-      this._useXHRUploads();
+      this.#useXHRUploads();
     }
 
-    this._uppyInstance.on("file-added", (file) => {
+    this.uppyWrapper.uppyInstance.on("file-added", (file) => {
       run(() => {
-        if (isPrivateMessage) {
+        if (this.composerModel.privateMessage) {
           file.meta.for_private_message = true;
         }
       });
     });
 
-    this._uppyInstance.on("progress", (progress) => {
+    this.uppyWrapper.uppyInstance.on("progress", (progress) => {
       run(() => {
         if (this.isDestroying || this.isDestroyed) {
           return;
         }
 
-        this.set("uploadProgress", progress);
+        this.composer.set("uploadProgress", progress);
       });
     });
 
-    this._uppyInstance.on("file-removed", (file, reason) => {
+    this.uppyWrapper.uppyInstance.on("file-removed", (file, reason) => {
       run(() => {
         // we handle the cancel-all event specifically, so no need
         // to do anything here. this event is also fired when some files
@@ -248,22 +279,24 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
           file.id
         );
         file.meta.cancelled = true;
-        this._removeInProgressUpload(file.id);
-        this._resetUpload(file, { removePlaceholder: true });
-        if (this.inProgressUploads.length === 0) {
-          this.set("userCancelled", true);
-          this._uppyInstance.cancelAll();
+        this.#removeInProgressUpload(file.id);
+        this.#resetUpload(file, { removePlaceholder: true });
+        if (this.#inProgressUploads.length === 0) {
+          this.#userCancelled = true;
+          this.uppyWrapper.uppyInstance.cancelAll();
         }
       });
     });
 
-    this._uppyInstance.on("upload-progress", (file, progress) => {
+    this.uppyWrapper.uppyInstance.on("upload-progress", (file, progress) => {
       run(() => {
         if (this.isDestroying || this.isDestroyed) {
           return;
         }
 
-        const upload = this.inProgressUploads.find((upl) => upl.id === file.id);
+        const upload = this.#inProgressUploads.find(
+          (upl) => upl.id === file.id
+        );
         if (upload) {
           const percentage = Math.round(
             (progress.bytesUploaded / progress.bytesTotal) * 100
@@ -273,15 +306,15 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
       });
     });
 
-    this._uppyInstance.on("upload", (data) => {
+    this.uppyWrapper.uppyInstance.on("upload", (data) => {
       run(() => {
-        this._addNeedProcessing(data.fileIDs.length);
+        this.uppyWrapper.addNeedProcessing(data.fileIDs.length);
 
         const files = data.fileIDs.map((fileId) =>
-          this._uppyInstance.getFile(fileId)
+          this.uppyWrapper.uppyInstance.getFile(fileId)
         );
 
-        this.setProperties({
+        this.composer.setProperties({
           isProcessingUpload: true,
           isCancellable: false,
         });
@@ -290,7 +323,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
           // The inProgressUploads is meant to be used to display these uploads
           // in a UI, and Ember will only update the array in the UI if pushObject
           // is used to notify it.
-          this.inProgressUploads.pushObject(
+          this.#inProgressUploads.pushObject(
             EmberObject.create({
               fileName: file.name,
               id: file.id,
@@ -298,12 +331,12 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
               extension: file.extension,
             })
           );
-          const placeholder = this._uploadPlaceholder(file);
-          this.placeholders[file.id] = {
+          const placeholder = this.#uploadPlaceholder(file);
+          this.#placeholders[file.id] = {
             uploadPlaceholder: placeholder,
           };
 
-          if (this.useUploadPlaceholders) {
+          if (this.#useUploadPlaceholders) {
             this.appEvents.trigger(
               `${this.composerEventPrefix}:insert-text`,
               placeholder
@@ -318,12 +351,12 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
       });
     });
 
-    this._uppyInstance.on("upload-success", (file, response) => {
+    this.uppyWrapper.uppyInstance.on("upload-success", (file, response) => {
       run(async () => {
-        if (!this._uppyInstance) {
+        if (!this.uppyWrapper.uppyInstance) {
           return;
         }
-        this._removeInProgressUpload(file.id);
+        this.#removeInProgressUpload(file.id);
         let upload = response.body;
         const markdown = await this.uploadMarkdownResolvers.reduce(
           (md, resolver) => resolver(upload) || md,
@@ -336,40 +369,40 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
           file,
           upload.url,
           () => {
-            if (this.useUploadPlaceholders) {
+            if (this.#useUploadPlaceholders) {
               this.appEvents.trigger(
                 `${this.composerEventPrefix}:replace-text`,
-                this.placeholders[file.id].uploadPlaceholder.trim(),
+                this.#placeholders[file.id].uploadPlaceholder.trim(),
                 markdown
               );
             }
-            this._resetUpload(file, { removePlaceholder: false });
+            this.#resetUpload(file, { removePlaceholder: false });
             this.appEvents.trigger(
               `${this.composerEventPrefix}:upload-success`,
               file.name,
               upload
             );
 
-            if (this.inProgressUploads.length === 0) {
+            if (this.#inProgressUploads.length === 0) {
               this.appEvents.trigger(
                 `${this.composerEventPrefix}:all-uploads-complete`
               );
-              this._displayBufferedErrors();
-              this._reset();
+              this.#displayBufferedErrors();
+              this.#reset();
             }
           }
         );
       });
     });
 
-    this._uppyInstance.on("upload-error", this._handleUploadError);
+    this.uppyWrapper.uppyInstance.on("upload-error", this._handleUploadError);
 
-    this._uppyInstance.on("cancel-all", () => {
+    this.uppyWrapper.uppyInstance.on("cancel-all", () => {
       // Do the manual cancelling work only if the user clicked cancel
-      if (this.userCancelled) {
-        Object.values(this.placeholders).forEach((data) => {
+      if (this.#userCancelled) {
+        Object.values(this.#placeholders).forEach((data) => {
           run(() => {
-            if (this.useUploadPlaceholders) {
+            if (this.#useUploadPlaceholders) {
               this.appEvents.trigger(
                 `${this.composerEventPrefix}:replace-text`,
                 data.uploadPlaceholder,
@@ -379,68 +412,63 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
           });
         });
 
-        this.set("userCancelled", false);
-        this._reset();
+        this.#userCancelled = false;
+        this.#reset();
 
         this.appEvents.trigger(`${this.composerEventPrefix}:uploads-cancelled`);
       }
     });
 
-    this._setupPreProcessors();
-    this._setupUIPlugins();
+    this.#setupPreProcessors();
 
-    this.uploadTargetBound = true;
-    this._uppyReady();
-  },
+    this.uppyWrapper.uppyInstance.use(DropTarget, { target: element });
 
-  // This should be overridden in a child component if you need to
-  // hook into uppy events and be sure that everything is already
-  // set up for _uppyInstance.
-  _uppyReady() {},
+    this.#uploadTargetBound = true;
+    this.#bindMobileUploadButton();
+  }
 
   @bind
   _handleUploadError(file, error, response) {
-    this._removeInProgressUpload(file.id);
-    this._resetUpload(file, { removePlaceholder: true });
+    this.#removeInProgressUpload(file.id);
+    this.#resetUpload(file, { removePlaceholder: true });
 
     file.meta.error = error;
 
-    if (!this.userCancelled) {
-      this._bufferUploadError(response || error, file.name);
+    if (!this.#userCancelled) {
+      this.#bufferUploadError(response || error, file.name);
       this.appEvents.trigger(`${this.composerEventPrefix}:upload-error`, file);
     }
-    if (this.inProgressUploads.length === 0) {
-      this._displayBufferedErrors();
-      this._reset();
+    if (this.#inProgressUploads.length === 0) {
+      this.#displayBufferedErrors();
+      this.#reset();
     }
-  },
+  }
 
-  _removeInProgressUpload(fileId) {
-    this.set(
-      "inProgressUploads",
-      this.inProgressUploads.filter((upl) => upl.id !== fileId)
+  #removeInProgressUpload(fileId) {
+    this.#inProgressUploads = this.#inProgressUploads.filter(
+      (upl) => upl.id !== fileId
     );
-  },
+  }
 
-  _displayBufferedErrors() {
-    if (this.bufferedUploadErrors.length === 0) {
+  #displayBufferedErrors() {
+    if (this.#bufferedUploadErrors.length === 0) {
       return;
-    } else if (this.bufferedUploadErrors.length === 1) {
+    } else if (this.#bufferedUploadErrors.length === 1) {
       displayErrorForUpload(
-        this.bufferedUploadErrors[0].data,
+        this.#bufferedUploadErrors[0].data,
         this.siteSettings,
-        this.bufferedUploadErrors[0].fileName
+        this.#bufferedUploadErrors[0].fileName
       );
     } else {
-      displayErrorForBulkUpload(this.bufferedUploadErrors);
+      displayErrorForBulkUpload(this.#bufferedUploadErrors);
     }
-  },
+  }
 
-  _bufferUploadError(data, fileName) {
-    this.bufferedUploadErrors.push({ data, fileName });
-  },
+  #bufferUploadError(data, fileName) {
+    this.#bufferedUploadErrors.push({ data, fileName });
+  }
 
-  _setupPreProcessors() {
+  #setupPreProcessors() {
     const checksumPreProcessor = {
       pluginClass: UppyChecksum,
       optionsResolverFn: ({ capabilities }) => {
@@ -457,19 +485,18 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
     [this.uploadPreProcessors, checksumPreProcessor]
       .flat()
       .forEach(({ pluginClass, optionsResolverFn }) => {
-        this._useUploadPlugin(
+        this.uppyWrapper.useUploadPlugin(
           pluginClass,
           optionsResolverFn({
             composerModel: this.composerModel,
-            composerElement: this.composerElement,
             capabilities: this.capabilities,
             isMobileDevice: this.site.isMobileDevice,
           })
         );
       });
 
-    this._onPreProcessProgress((file) => {
-      let placeholderData = this.placeholders[file.id];
+    this.uppyWrapper.onPreProcessProgress((file) => {
+      let placeholderData = this.#placeholders[file.id];
       placeholderData.processingPlaceholder = `[${I18n.t(
         "processing_filename",
         {
@@ -493,10 +520,10 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
       );
     });
 
-    this._onPreProcessComplete(
+    this.uppyWrapper.onPreProcessComplete(
       (file) => {
         run(() => {
-          let placeholderData = this.placeholders[file.id];
+          let placeholderData = this.#placeholders[file.id];
           this.appEvents.trigger(
             `${this.composerEventPrefix}:replace-text`,
             placeholderData.processingPlaceholder,
@@ -506,7 +533,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
       },
       () => {
         run(() => {
-          this.setProperties({
+          this.composer.setProperties({
             isProcessingUpload: false,
             isCancellable: true,
           });
@@ -516,14 +543,10 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
         });
       }
     );
-  },
+  }
 
-  _setupUIPlugins() {
-    this._uppyInstance.use(DropTarget, this._uploadDropTargetOptions());
-  },
-
-  _uploadFilenamePlaceholder(file) {
-    const filename = this._filenamePlaceholder(file);
+  #uploadFilenamePlaceholder(file) {
+    const filename = this.#filenamePlaceholder(file);
 
     // when adding two separate files with the same filename search for matching
     // placeholder already existing in the editor ie [Uploading: test.png…]
@@ -533,9 +556,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
       filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
     })}\\]\\(\\)`;
     const globalRegex = new RegExp(regexString, "g");
-    const matchingPlaceholder = this.get(
-      `composerModel.${this.composerModelContentKey}`
-    ).match(globalRegex);
+    const matchingPlaceholder = this.composerModel.reply.match(globalRegex);
     if (matchingPlaceholder) {
       // get last matching placeholder and its consecutive nr in regex
       // capturing group and apply +1 to the placeholder
@@ -548,58 +569,58 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
     }
 
     return filename;
-  },
+  }
 
-  _uploadPlaceholder(file) {
+  #uploadPlaceholder(file) {
     const clipboard = I18n.t("clipboard");
-    const uploadFilenamePlaceholder = this._uploadFilenamePlaceholder(file);
+    const uploadFilenamePlaceholder = this.#uploadFilenamePlaceholder(file);
     const filename = uploadFilenamePlaceholder
       ? uploadFilenamePlaceholder
       : clipboard;
 
     let placeholder = `[${I18n.t("uploading_filename", { filename })}]()\n`;
-    if (!this._cursorIsOnEmptyLine()) {
+    if (!this.#cursorIsOnEmptyLine()) {
       placeholder = `\n${placeholder}`;
     }
 
     return placeholder;
-  },
+  }
 
-  _useXHRUploads() {
-    this._uppyInstance.use(XHRUpload, {
+  #useXHRUploads() {
+    this.uppyWrapper.uppyInstance.use(XHRUpload, {
       endpoint: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
       headers: () => ({
         "X-CSRF-Token": this.session.csrfToken,
       }),
     });
-  },
+  }
 
-  _reset() {
-    this._uppyInstance?.cancelAll();
-    this.setProperties({
+  #reset() {
+    this.uppyWrapper.uppyInstance?.cancelAll();
+    this.composer.setProperties({
       uploadProgress: 0,
       isUploading: false,
       isProcessingUpload: false,
       isCancellable: false,
-      inProgressUploads: [],
-      bufferedUploadErrors: [],
     });
-    this._resetPreProcessors();
-    this.fileInputEl.value = "";
-  },
+    this.#inProgressUploads = [];
+    this.#bufferedUploadErrors = [];
+    this.uppyWrapper.resetPreProcessors();
+    this.#fileInputEl.value = "";
+  }
 
-  _resetUpload(file, opts) {
+  #resetUpload(file, opts) {
     if (opts.removePlaceholder) {
       this.appEvents.trigger(
         `${this.composerEventPrefix}:replace-text`,
-        this.placeholders[file.id].uploadPlaceholder,
+        this.#placeholders[file.id].uploadPlaceholder,
         ""
       );
     }
-  },
+  }
 
   @bind
-  pasteEventListener(event) {
+  _pasteEventListener(event) {
     if (
       document.activeElement !== document.querySelector(this.editorInputClass)
     ) {
@@ -618,7 +639,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
     if (event && event.clipboardData && event.clipboardData.files) {
       this._addFiles([...event.clipboardData.files], { pasted: true });
     }
-  },
+  }
 
   @bind
   async _addFiles(files, opts = {}) {
@@ -629,7 +650,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
     files = Array.isArray(files) ? files : [files];
 
     try {
-      this._uppyInstance.addFiles(
+      this.uppyWrapper.uppyInstance.addFiles(
         files.map((file) => {
           return {
             source: this.uppyId,
@@ -645,13 +666,9 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
         id: "discourse.upload.uppy-add-files-error",
       });
     }
-  },
+  }
 
-  showUploadSelector(toolbarEvent) {
-    this.send("showUploadSelector", toolbarEvent);
-  },
-
-  _bindMobileUploadButton() {
+  #bindMobileUploadButton() {
     if (this.site.mobileView) {
       this.mobileUploadButton = document.getElementById(
         this.mobileFileUploaderId
@@ -662,35 +679,37 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, {
         false
       );
     }
-  },
+  }
 
   @bind
   _mobileUploadButtonEventListener() {
-    document.getElementById(this.fileUploadElementId).click();
-  },
+    this.#fileInputEl.click();
+  }
 
-  _unbindMobileUploadButton() {
+  #unbindMobileUploadButton() {
     this.mobileUploadButton?.removeEventListener(
       "click",
       this._mobileUploadButtonEventListener
     );
-  },
+  }
 
-  _filenamePlaceholder(data) {
+  #filenamePlaceholder(data) {
     return data.name.replace(/\u200B-\u200D\uFEFF]/g, "");
-  },
+  }
 
-  _resetUploadFilenamePlaceholder() {
-    this.set("uploadFilenamePlaceholder", null);
-  },
+  #findMatchingUploadHandler(fileName) {
+    return this.uploadHandlers.find((handler) => {
+      const ext = handler.extensions.join("|");
+      const regex = new RegExp(`\\.(${ext})$`, "i");
+      return regex.test(fileName);
+    });
+  }
 
-  // target must be provided as a DOM element, however the
-  // onDragOver and onDragLeave callbacks can also be provided.
-  // it is advisable to debounce/add a setTimeout timer when
-  // doing anything in these callbacks to avoid jumping. uppy
-  // also adds a .uppy-is-drag-over class to the target element by
-  // default onDragOver and removes it onDragLeave
-  _uploadDropTargetOptions() {
-    return { target: this.element };
-  },
-});
+  #cursorIsOnEmptyLine() {
+    const textArea = this.#editorEl.querySelector(this.editorInputClass);
+    const selectionStart = textArea.selectionStart;
+    return (
+      selectionStart === 0 || textArea.value.charAt(selectionStart - 1) === "\n"
+    );
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js b/app/assets/javascripts/discourse/app/lib/uppy/s3-multipart.js
similarity index 80%
rename from app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js
rename to app/assets/javascripts/discourse/app/lib/uppy/s3-multipart.js
index e2ca00e160a..c014061d71e 100644
--- a/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js
+++ b/app/assets/javascripts/discourse/app/lib/uppy/s3-multipart.js
@@ -1,17 +1,26 @@
-import Mixin from "@ember/object/mixin";
+import { setOwner } from "@ember/owner";
+import { service } from "@ember/service";
 import AwsS3Multipart from "@uppy/aws-s3-multipart";
 import { Promise } from "rsvp";
 import { ajax } from "discourse/lib/ajax";
-import { bind } from "discourse-common/utils/decorators";
 
 const RETRY_DELAYS = [0, 1000, 3000, 5000];
 const MB = 1024 * 1024;
 
-export default Mixin.create({
-  _useS3MultipartUploads() {
-    this.set("usingS3MultipartUploads", true);
+export default class UppyS3Multipart {
+  @service siteSettings;
 
-    this._uppyInstance.use(AwsS3Multipart, {
+  constructor(owner, { uploadRootPath, errorHandler, uppyWrapper }) {
+    setOwner(this, owner);
+    this.uploadRootPath = uploadRootPath;
+    this.uppyWrapper = uppyWrapper;
+    this.errorHandler = errorHandler;
+  }
+
+  apply(uppyInstance) {
+    this.uppyInstance = uppyInstance;
+
+    this.uppyInstance.use(AwsS3Multipart, {
       // controls how many simultaneous _chunks_ are uploaded, not files,
       // which in turn controls the minimum number of chunks presigned
       // in each batch (limit / 2)
@@ -36,20 +45,19 @@ export default Mixin.create({
         }
       },
 
-      createMultipartUpload: this._createMultipartUpload,
-      prepareUploadParts: this._prepareUploadParts,
-      completeMultipartUpload: this._completeMultipartUpload,
-      abortMultipartUpload: this._abortMultipartUpload,
+      createMultipartUpload: this.#createMultipartUpload.bind(this),
+      prepareUploadParts: this.#prepareUploadParts.bind(this),
+      completeMultipartUpload: this.#completeMultipartUpload.bind(this),
+      abortMultipartUpload: this.#abortMultipartUpload.bind(this),
 
       // we will need a listParts function at some point when we want to
       // resume multipart uploads; this is used by uppy to figure out
       // what parts are uploaded and which still need to be
     });
-  },
+  }
 
-  @bind
-  _createMultipartUpload(file) {
-    this._uppyInstance.emit("create-multipart", file.id);
+  #createMultipartUpload(file) {
+    this.uppyInstance.emit("create-multipart", file.id);
 
     const data = {
       file_name: file.name,
@@ -71,7 +79,7 @@ export default Mixin.create({
       data,
       // uppy is inconsistent, an error here fires the upload-error event
     }).then((responseData) => {
-      this._uppyInstance.emit("create-multipart-success", file.id);
+      this.uppyInstance.emit("create-multipart-success", file.id);
 
       file.meta.unique_identifier = responseData.unique_identifier;
       return {
@@ -79,10 +87,9 @@ export default Mixin.create({
         key: responseData.key,
       };
     });
-  },
+  }
 
-  @bind
-  _prepareUploadParts(file, partData) {
+  #prepareUploadParts(file, partData) {
     if (file.preparePartsRetryAttempts === undefined) {
       file.preparePartsRetryAttempts = 0;
     }
@@ -96,7 +103,7 @@ export default Mixin.create({
       .then((data) => {
         if (file.preparePartsRetryAttempts) {
           delete file.preparePartsRetryAttempts;
-          this._consoleDebug(
+          this.uppyWrapper.debug.log(
             `[uppy] Retrying batch fetch for ${file.id} was successful, continuing.`
           );
         }
@@ -118,27 +125,26 @@ export default Mixin.create({
           file.preparePartsRetryAttempts += 1;
           const attemptsLeft =
             RETRY_DELAYS.length - file.preparePartsRetryAttempts + 1;
-          this._consoleDebug(
+          this.uppyWrapper.debug.log(
             `[uppy] Fetching a batch of upload part URLs for ${file.id} failed with status ${status}, retrying ${attemptsLeft} more times...`
           );
           return Promise.reject({ source: { status } });
         } else {
-          this._consoleDebug(
+          this.uppyWrapper.debug.log(
             `[uppy] Fetching a batch of upload part URLs for ${file.id} failed too many times, throwing error.`
           );
           // uppy is inconsistent, an error here does not fire the upload-error event
-          this._handleUploadError(file, err);
+          this.handleUploadError(file, err);
         }
       });
-  },
+  }
 
-  @bind
-  _completeMultipartUpload(file, data) {
+  #completeMultipartUpload(file, data) {
     if (file.meta.cancelled) {
       return;
     }
 
-    this._uppyInstance.emit("complete-multipart", file.id);
+    this.uppyInstance.emit("complete-multipart", file.id);
     const parts = data.parts.map((part) => {
       return { part_number: part.PartNumber, etag: part.ETag };
     });
@@ -153,13 +159,12 @@ export default Mixin.create({
       }),
       // uppy is inconsistent, an error here fires the upload-error event
     }).then((responseData) => {
-      this._uppyInstance.emit("complete-multipart-success", file.id);
+      this.uppyInstance.emit("complete-multipart-success", file.id);
       return responseData;
     });
-  },
+  }
 
-  @bind
-  _abortMultipartUpload(file, { key, uploadId }) {
+  #abortMultipartUpload(file, { key, uploadId }) {
     // if the user cancels the upload before the key and uploadId
     // are stored from the createMultipartUpload response then they
     // will not be set, and we don't have to abort the upload because
@@ -184,7 +189,7 @@ export default Mixin.create({
       },
       // uppy is inconsistent, an error here does not fire the upload-error event
     }).catch((err) => {
-      this._handleUploadError(file, err);
+      this.errorHandler(file, err);
     });
-  },
-});
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/mixins/upload-debugging.js b/app/assets/javascripts/discourse/app/lib/uppy/upload-debugging.js
similarity index 57%
rename from app/assets/javascripts/discourse/app/mixins/upload-debugging.js
rename to app/assets/javascripts/discourse/app/lib/uppy/upload-debugging.js
index 7111334b9db..05c6f0b3477 100644
--- a/app/assets/javascripts/discourse/app/mixins/upload-debugging.js
+++ b/app/assets/javascripts/discourse/app/lib/uppy/upload-debugging.js
@@ -1,15 +1,22 @@
 import { warn } from "@ember/debug";
-import Mixin from "@ember/object/mixin";
+import { setOwner } from "@ember/owner";
+import { service } from "@ember/service";
 
-export default Mixin.create({
-  _consoleDebug(msg) {
+export default class UppyUploadDebugging {
+  @service siteSettings;
+
+  constructor(owner) {
+    setOwner(this, owner);
+  }
+
+  log(msg) {
     if (this.siteSettings.enable_upload_debug_mode) {
       // eslint-disable-next-line no-console
       console.log(msg);
     }
-  },
+  }
 
-  _consolePerformanceTiming(timing) {
+  #consolePerformanceTiming(timing) {
     // Sometimes performance.measure can fail to return a PerformanceMeasure
     // object, in this case we can't log anything so return to prevent errors.
     if (!timing) {
@@ -19,27 +26,25 @@ export default Mixin.create({
     const minutes = Math.floor(timing.duration / 60000);
     const seconds = ((timing.duration % 60000) / 1000).toFixed(0);
     const duration = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
-    this._consoleDebug(
-      `${timing.name}:\n duration: ${duration} (${timing.duration}ms)`
-    );
-  },
+    this.log(`${timing.name}:\n duration: ${duration} (${timing.duration}ms)`);
+  }
 
-  _performanceApiSupport() {
-    this._performanceMark("testing support 1");
-    this._performanceMark("testing support 2");
-    const perfMeasure = this._performanceMeasure(
+  #performanceApiSupport() {
+    this.#performanceMark("testing support 1");
+    this.#performanceMark("testing support 2");
+    const perfMeasure = this.#performanceMeasure(
       "performance api support",
       "testing support 1",
       "testing support 2"
     );
     return perfMeasure;
-  },
+  }
 
-  _performanceMark(markName) {
+  #performanceMark(markName) {
     return performance.mark(markName);
-  },
+  }
 
-  _performanceMeasure(measureName, startMark, endMark) {
+  #performanceMeasure(measureName, startMark, endMark) {
     let measureResult;
     try {
       measureResult = performance.measure(measureName, startMark, endMark);
@@ -54,36 +59,36 @@ export default Mixin.create({
       }
     }
     return measureResult;
-  },
+  }
 
-  _instrumentUploadTimings() {
-    if (!this._performanceApiSupport()) {
+  instrumentUploadTimings(uppy) {
+    if (!this.#performanceApiSupport()) {
       warn(
-        "Some browsers do not return a PerformanceMeasure when calling this._performanceMark, disabling instrumentation. See https://developer.mozilla.org/en-US/docs/Web/API/this._performanceMeasure#return_value and https://bugzilla.mozilla.org/show_bug.cgi?id=1724645",
+        "Some browsers do not return a PerformanceMeasure when calling this.#performanceMark, disabling instrumentation. See https://developer.mozilla.org/en-US/docs/Web/API/this.#performanceMeasure#return_value and https://bugzilla.mozilla.org/show_bug.cgi?id=1724645",
         { id: "discourse.upload-debugging" }
       );
       return;
     }
 
-    this._uppyInstance.on("upload", (data) => {
+    uppy.on("upload", (data) => {
       data.fileIDs.forEach((fileId) =>
-        this._performanceMark(`upload-${fileId}-start`)
+        this.#performanceMark(`upload-${fileId}-start`)
       );
     });
 
-    this._uppyInstance.on("create-multipart", (fileId) => {
-      this._performanceMark(`upload-${fileId}-create-multipart`);
+    uppy.on("create-multipart", (fileId) => {
+      this.#performanceMark(`upload-${fileId}-create-multipart`);
     });
 
-    this._uppyInstance.on("create-multipart-success", (fileId) => {
-      this._performanceMark(`upload-${fileId}-create-multipart-success`);
+    uppy.on("create-multipart-success", (fileId) => {
+      this.#performanceMark(`upload-${fileId}-create-multipart-success`);
     });
 
-    this._uppyInstance.on("complete-multipart", (fileId) => {
-      this._performanceMark(`upload-${fileId}-complete-multipart`);
+    uppy.on("complete-multipart", (fileId) => {
+      this.#performanceMark(`upload-${fileId}-complete-multipart`);
 
-      this._consolePerformanceTiming(
-        this._performanceMeasure(
+      this.#consolePerformanceTiming(
+        this.#performanceMeasure(
           `upload-${fileId}-multipart-all-parts-complete`,
           `upload-${fileId}-create-multipart-success`,
           `upload-${fileId}-complete-multipart`
@@ -91,27 +96,27 @@ export default Mixin.create({
       );
     });
 
-    this._uppyInstance.on("complete-multipart-success", (fileId) => {
-      this._performanceMark(`upload-${fileId}-complete-multipart-success`);
+    uppy.on("complete-multipart-success", (fileId) => {
+      this.#performanceMark(`upload-${fileId}-complete-multipart-success`);
 
-      this._consolePerformanceTiming(
-        this._performanceMeasure(
+      this.#consolePerformanceTiming(
+        this.#performanceMeasure(
           `upload-${fileId}-multipart-total-network-exclusive-complete-multipart`,
           `upload-${fileId}-create-multipart`,
           `upload-${fileId}-complete-multipart`
         )
       );
 
-      this._consolePerformanceTiming(
-        this._performanceMeasure(
+      this.#consolePerformanceTiming(
+        this.#performanceMeasure(
           `upload-${fileId}-multipart-total-network-inclusive-complete-multipart`,
           `upload-${fileId}-create-multipart`,
           `upload-${fileId}-complete-multipart-success`
         )
       );
 
-      this._consolePerformanceTiming(
-        this._performanceMeasure(
+      this.#consolePerformanceTiming(
+        this.#performanceMeasure(
           `upload-${fileId}-multipart-complete-convert-to-upload`,
           `upload-${fileId}-complete-multipart`,
           `upload-${fileId}-complete-multipart-success`
@@ -119,15 +124,15 @@ export default Mixin.create({
       );
     });
 
-    this._uppyInstance.on("upload-success", (file) => {
-      this._performanceMark(`upload-${file.id}-end`);
-      this._consolePerformanceTiming(
-        this._performanceMeasure(
+    uppy.on("upload-success", (file) => {
+      this.#performanceMark(`upload-${file.id}-end`);
+      this.#consolePerformanceTiming(
+        this.#performanceMeasure(
           `upload-${file.id}-multipart-total-inclusive-preprocessing`,
           `upload-${file.id}-start`,
           `upload-${file.id}-end`
         )
       );
     });
-  },
-});
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/lib/uppy/uppy-upload.js b/app/assets/javascripts/discourse/app/lib/uppy/uppy-upload.js
new file mode 100644
index 00000000000..174bdbb38cc
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/lib/uppy/uppy-upload.js
@@ -0,0 +1,549 @@
+import { tracked } from "@glimmer/tracking";
+import { warn } from "@ember/debug";
+import EmberObject from "@ember/object";
+import { getOwner, setOwner } from "@ember/owner";
+import { run } from "@ember/runloop";
+import { service } from "@ember/service";
+import { TrackedArray } from "@ember-compat/tracked-built-ins";
+import AwsS3 from "@uppy/aws-s3";
+import Uppy from "@uppy/core";
+import DropTarget from "@uppy/drop-target";
+import XHRUpload from "@uppy/xhr-upload";
+import { ajax, updateCsrfToken } from "discourse/lib/ajax";
+import {
+  bindFileInputChangeListener,
+  displayErrorForUpload,
+  validateUploadedFile,
+} from "discourse/lib/uploads";
+import UppyS3Multipart from "discourse/lib/uppy/s3-multipart";
+import UppyWrapper from "discourse/lib/uppy/wrapper";
+import UppyChecksum from "discourse/lib/uppy-checksum-plugin";
+import UppyChunkedUploader from "discourse/lib/uppy-chunked-uploader-plugin";
+import getUrl from "discourse-common/lib/get-url";
+import { deepMerge } from "discourse-common/lib/object";
+import { bind } from "discourse-common/utils/decorators";
+import I18n from "discourse-i18n";
+
+export const HUGE_FILE_THRESHOLD_BYTES = 104_857_600; // 100MB
+
+const DEFAULT_CONFIG = {
+  uploadDone: null,
+  uploadError: null,
+  autoStartUploads: true,
+  uploadUrl: null,
+  uploadRootPath: "/uploads",
+  validateUploadedFilesOptions: {},
+  additionalParams: {},
+  maxFiles: null,
+
+  /**
+   * Overridable for custom file validations, executed before uploading.
+   *
+   * @param {object} file
+   *
+   * @returns {boolean}
+   */
+  isUploadedFileAllowed: () => true,
+
+  /** set file data on a per-file basis */
+  perFileData: null,
+
+  uploadDropTargetOptions: null,
+  preventDirectS3Uploads: false,
+  useChunkedUploads: false,
+  useMultipartUploadsIfAvailable: false,
+  uppyReady: null,
+  onProgressUploadsChanged: null,
+};
+
+// Merges incoming config with defaults, without actually evaluating
+// any getters on the incoming config.
+function lazyMergeConfig(config) {
+  const mergedConfig = {};
+
+  const incomingDescriptors = Object.getOwnPropertyDescriptors(config);
+  const defaultDescriptors = Object.getOwnPropertyDescriptors(DEFAULT_CONFIG);
+
+  Object.defineProperties(mergedConfig, {
+    ...defaultDescriptors,
+    ...incomingDescriptors,
+  });
+
+  return mergedConfig;
+}
+
+const REQUIRED_CONFIG_KEYS = ["id", "uploadDone"];
+function validateConfig(config) {
+  for (const key of REQUIRED_CONFIG_KEYS) {
+    if (!config[key]) {
+      throw new Error(`Missing required UppyUpload config: ${key}`);
+    }
+  }
+}
+
+export default class UppyUpload {
+  @service dialog;
+  @service messageBus;
+  @service appEvents;
+  @service siteSettings;
+  @service capabilities;
+  @service session;
+
+  @tracked uploading = false;
+  @tracked processing = false;
+  @tracked uploadProgress = 0;
+  @tracked allowMultipleFiles;
+  @tracked filesAwaitingUpload = false;
+  @tracked cancellable = false;
+
+  inProgressUploads = new TrackedArray();
+
+  uppyWrapper;
+
+  #fileInputEventListener;
+  #usingS3Uploads;
+
+  _fileInputEl;
+
+  constructor(owner, config) {
+    setOwner(this, owner);
+    this.uppyWrapper = new UppyWrapper(owner);
+    this.config = lazyMergeConfig(config);
+    validateConfig(this.config);
+  }
+
+  teardown() {
+    this.messageBus.unsubscribe(`/uploads/${this.config.type}`);
+
+    this._fileInputEl?.removeEventListener(
+      "change",
+      this.#fileInputEventListener
+    );
+    this.appEvents.off(
+      `upload-mixin:${this.config.id}:add-files`,
+      this.addFiles
+    );
+    this.appEvents.off(
+      `upload-mixin:${this.config.id}:cancel-upload`,
+      this._cancelSingleUpload
+    );
+    this.uppyWrapper.uppyInstance?.close();
+  }
+
+  setup(fileInputEl) {
+    this._fileInputEl = fileInputEl;
+
+    this.allowMultipleFiles = this._fileInputEl.multiple;
+
+    this.#bindFileInputChange();
+
+    this.uppyWrapper.uppyInstance = new Uppy({
+      id: this.config.id,
+      autoProceed: this.config.autoStartUploads,
+
+      // need to use upload_type because uppy overrides type with the
+      // actual file type
+      meta: deepMerge(
+        { upload_type: this.config.type },
+        this.config.additionalParams
+      ),
+
+      onBeforeFileAdded: (currentFile) => {
+        const validationOpts = deepMerge(
+          {
+            bypassNewUserRestriction: true,
+            user: this.currentUser,
+            siteSettings: this.siteSettings,
+            validateSize: true,
+          },
+          this.config.validateUploadedFilesOptions
+        );
+        const isValid =
+          validateUploadedFile(currentFile, validationOpts) &&
+          this.config.isUploadedFileAllowed(currentFile);
+        Object.assign(this, {
+          uploadProgress: 0,
+          uploading: isValid && this.config.autoStartUploads,
+          filesAwaitingUpload: !this.config.autoStartUploads,
+          cancellable: isValid && this.config.autoStartUploads,
+        });
+        return isValid;
+      },
+
+      onBeforeUpload: (files) => {
+        let tooMany = false;
+        const fileCount = Object.keys(files).length;
+        const maxFiles =
+          this.config.maxFiles || this.siteSettings.simultaneous_uploads;
+
+        if (this.allowMultipleFiles) {
+          tooMany = maxFiles > 0 && fileCount > maxFiles;
+        } else {
+          tooMany = fileCount > 1;
+        }
+
+        if (tooMany) {
+          this.dialog.alert(
+            I18n.t("post.errors.too_many_dragged_and_dropped_files", {
+              count: this.allowMultipleFiles ? maxFiles : 1,
+            })
+          );
+          this.#reset();
+          return false;
+        }
+
+        Object.values(files).forEach((file) => {
+          deepMerge(file.meta, this.config.perFileData?.(file));
+        });
+      },
+    });
+
+    if (this.config.uploadDropTargetOptions) {
+      // DropTarget is a UI plugin, only preprocessors must call _useUploadPlugin
+      this.uppyWrapper.uppyInstance.use(
+        DropTarget,
+        this.config.uploadDropTargetOptions
+      );
+    }
+
+    this.uppyWrapper.uppyInstance.on("progress", (progress) => {
+      this.uploadProgress = progress;
+    });
+
+    this.uppyWrapper.uppyInstance.on("upload", (data) => {
+      this.uppyWrapper.addNeedProcessing(data.fileIDs.length);
+      const files = data.fileIDs.map((fileId) =>
+        this.uppyWrapper.uppyInstance.getFile(fileId)
+      );
+      this.processing = true;
+      this.cancellable = false;
+      files.forEach((file) => {
+        this.inProgressUploads.push(
+          EmberObject.create({
+            fileName: file.name,
+            id: file.id,
+            progress: 0,
+            extension: file.extension,
+            processing: false,
+          })
+        );
+        this.#triggerInProgressUploadsEvent();
+      });
+    });
+
+    this.uppyWrapper.uppyInstance.on("upload-progress", (file, progress) => {
+      run(() => {
+        const upload = this.inProgressUploads.find((upl) => upl.id === file.id);
+        if (upload) {
+          const percentage = Math.round(
+            (progress.bytesUploaded / progress.bytesTotal) * 100
+          );
+          upload.set("progress", percentage);
+        }
+      });
+    });
+
+    this.uppyWrapper.uppyInstance.on("upload-success", (file, response) => {
+      if (this.#usingS3Uploads) {
+        Object.assign(this, { uploading: false, processing: true });
+        this.#completeExternalUpload(file)
+          .then((completeResponse) => {
+            this.#removeInProgressUpload(file.id);
+            this.appEvents.trigger(
+              `upload-mixin:${this.config.id}:upload-success`,
+              file.name,
+              completeResponse
+            );
+            this.config.uploadDone(
+              deepMerge(completeResponse, { file_name: file.name })
+            );
+
+            this.#triggerInProgressUploadsEvent();
+            if (this.inProgressUploads.length === 0) {
+              this.#allUploadsComplete();
+            }
+          })
+          .catch((errResponse) => {
+            displayErrorForUpload(errResponse, this.siteSettings, file.name);
+            this.#triggerInProgressUploadsEvent();
+          });
+      } else {
+        this.#removeInProgressUpload(file.id);
+        const upload = response?.body || {};
+        this.appEvents.trigger(
+          `upload-mixin:${this.config.id}:upload-success`,
+          file.name,
+          upload
+        );
+        this.config.uploadDone(deepMerge(upload, { file_name: file.name }));
+
+        this.#triggerInProgressUploadsEvent();
+        if (this.inProgressUploads.length === 0) {
+          this.#allUploadsComplete();
+        }
+      }
+    });
+
+    this.uppyWrapper.uppyInstance.on(
+      "upload-error",
+      (file, error, response) => {
+        this.#removeInProgressUpload(file.id);
+        displayErrorForUpload(response || error, this.siteSettings, file.name);
+        this.#reset();
+      }
+    );
+
+    this.uppyWrapper.uppyInstance.on("file-removed", (file, reason) => {
+      run(() => {
+        // we handle the cancel-all event specifically, so no need
+        // to do anything here. this event is also fired when some files
+        // are handled by an upload handler
+        if (reason === "cancel-all") {
+          return;
+        }
+        this.appEvents.trigger(
+          `upload-mixin:${this.config.id}:upload-cancelled`,
+          file.id
+        );
+      });
+    });
+
+    if (this.siteSettings.enable_upload_debug_mode) {
+      this.uppyWrapper.debug.instrumentUploadTimings(
+        this.uppyWrapper.uppyInstance
+      );
+    }
+
+    // TODO (martin) preventDirectS3Uploads is necessary because some of
+    // the current upload mixin components, for example the emoji uploader,
+    // send the upload to custom endpoints that do fancy things in the rails
+    // controller with the upload or create additional data or records. we
+    // need a nice way to do this on complete-external-upload before we can
+    // allow these other uploaders to go direct to S3.
+    if (
+      this.siteSettings.enable_direct_s3_uploads &&
+      !this.config.preventDirectS3Uploads &&
+      !this.config.useChunkedUploads
+    ) {
+      if (this.config.useMultipartUploadsIfAvailable) {
+        new UppyS3Multipart(getOwner(this), {
+          uploadRootPath: this.config.uploadRootPath,
+          uppyWrapper: this.uppyWrapper,
+          errorHandler: this.config.uploadError,
+        }).apply(this.uppyWrapper.uppyInstance);
+      } else {
+        this.#useS3Uploads();
+      }
+    } else {
+      if (this.config.useChunkedUploads) {
+        this.#useChunkedUploads();
+      } else {
+        this.#useXHRUploads();
+      }
+    }
+
+    this.uppyWrapper.uppyInstance.on("cancel-all", () => {
+      this.appEvents.trigger(
+        `upload-mixin:${this.config.id}:uploads-cancelled`
+      );
+
+      if (this.inProgressUploads.length) {
+        this.inProgressUploads.length = 0; // Clear array in-place
+        this.#triggerInProgressUploadsEvent();
+      }
+    });
+
+    this.appEvents.on(
+      `upload-mixin:${this.config.id}:add-files`,
+      this.addFiles
+    );
+    this.appEvents.on(
+      `upload-mixin:${this.config.id}:cancel-upload`,
+      this._cancelSingleUpload
+    );
+    this.config.uppyReady?.();
+
+    // It is important that the UppyChecksum preprocessor is the last one to
+    // be added; the preprocessors are run in order and since other preprocessors
+    // may modify the file (e.g. the UppyMediaOptimization one), we need to
+    // checksum once we are sure the file data has "settled".
+    this.uppyWrapper.useUploadPlugin(UppyChecksum, {
+      capabilities: this.capabilities,
+    });
+  }
+
+  #triggerInProgressUploadsEvent() {
+    this.config.onProgressUploadsChanged?.(this.inProgressUploads);
+    this.appEvents.trigger(
+      `upload-mixin:${this.config.id}:in-progress-uploads`,
+      this.inProgressUploads
+    );
+  }
+
+  /**
+   * If auto upload is disabled, use this function to start the upload process.
+   */
+  startUpload() {
+    if (!this.filesAwaitingUpload) {
+      return;
+    }
+    if (!this.uppyWrapper.uppyInstance?.getFiles().length) {
+      return;
+    }
+    this.uploading = true;
+    return this.uppyWrapper.uppyInstance?.upload();
+  }
+
+  #useXHRUploads() {
+    this.uppyWrapper.uppyInstance.use(XHRUpload, {
+      endpoint: this.#xhrUploadUrl(),
+      headers: () => ({
+        "X-CSRF-Token": this.session.csrfToken,
+      }),
+    });
+  }
+
+  #useChunkedUploads() {
+    this.uppyWrapper.uppyInstance.use(UppyChunkedUploader, {
+      url: this.#xhrUploadUrl(),
+      headers: {
+        "X-CSRF-Token": this.session.csrfToken,
+      },
+    });
+  }
+
+  #useS3Uploads() {
+    this.#usingS3Uploads = true;
+    this.uppyWrapper.uppyInstance.use(AwsS3, {
+      getUploadParameters: (file) => {
+        const data = {
+          file_name: file.name,
+          file_size: file.size,
+          type: this.config.type,
+        };
+
+        // the sha1 checksum is set by the UppyChecksum plugin, except
+        // for in cases where the browser does not support the required
+        // crypto mechanisms or an error occurs. it is an additional layer
+        // of security, and not required.
+        if (file.meta.sha1_checksum) {
+          data.metadata = { "sha1-checksum": file.meta.sha1_checksum };
+        }
+
+        return ajax(`${this.config.uploadRootPath}/generate-presigned-put`, {
+          type: "POST",
+          data,
+        })
+          .then((response) => {
+            this.uppyWrapper.uppyInstance.setFileMeta(file.id, {
+              uniqueUploadIdentifier: response.unique_identifier,
+            });
+
+            return {
+              method: "put",
+              url: response.url,
+              headers: {
+                ...response.signed_headers,
+                "Content-Type": file.type,
+              },
+            };
+          })
+          .catch((errResponse) => {
+            displayErrorForUpload(errResponse, this.siteSettings, file.name);
+            this.#reset();
+          });
+      },
+    });
+  }
+
+  #xhrUploadUrl() {
+    const uploadUrl = this.config.uploadUrl || this.config.uploadRootPath;
+    return getUrl(uploadUrl) + ".json?client_id=" + this.messageBus?.clientId;
+  }
+
+  #bindFileInputChange() {
+    this.#fileInputEventListener = bindFileInputChangeListener(
+      this._fileInputEl,
+      this.addFiles
+    );
+  }
+
+  @bind
+  _cancelSingleUpload(data) {
+    this.uppyWrapper.uppyInstance.removeFile(data.fileId);
+    this.#removeInProgressUpload(data.fileId);
+  }
+
+  @bind
+  async addFiles(files, opts = {}) {
+    if (!this.session.csrfToken) {
+      await updateCsrfToken();
+    }
+
+    files = Array.isArray(files) ? files : [files];
+
+    try {
+      this.uppyWrapper.uppyInstance.addFiles(
+        files.map((file) => {
+          return {
+            source: this.config.id,
+            name: file.name,
+            type: file.type,
+            data: file,
+            meta: { pasted: opts.pasted },
+          };
+        })
+      );
+    } catch (err) {
+      warn(`error adding files to uppy: ${err}`, {
+        id: "discourse.upload.uppy-add-files-error",
+      });
+    }
+  }
+
+  #completeExternalUpload(file) {
+    return ajax(`${this.config.uploadRootPath}/complete-external-upload`, {
+      type: "POST",
+      data: deepMerge(
+        { unique_identifier: file.meta.uniqueUploadIdentifier },
+        this.config.additionalParams
+      ),
+    });
+  }
+
+  #reset() {
+    this.uppyWrapper.uppyInstance?.cancelAll();
+    Object.assign(this, {
+      uploading: false,
+      processing: false,
+      cancellable: false,
+      uploadProgress: 0,
+      filesAwaitingUpload: false,
+    });
+    this._fileInputEl.value = "";
+  }
+
+  #removeInProgressUpload(fileId) {
+    if (this.isDestroyed || this.isDestroying) {
+      return;
+    }
+
+    const index = this.inProgressUploads.findIndex((upl) => upl.id === fileId);
+    if (index === -1) {
+      return;
+    }
+    this.inProgressUploads.splice(index, 1);
+    this.#triggerInProgressUploadsEvent();
+  }
+
+  #allUploadsComplete() {
+    if (this.isDestroying || this.isDestroyed) {
+      return;
+    }
+
+    this.appEvents.trigger(
+      `upload-mixin:${this.config.id}:all-uploads-complete`
+    );
+    this.#reset();
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js b/app/assets/javascripts/discourse/app/lib/uppy/wrapper.js
similarity index 62%
rename from app/assets/javascripts/discourse/app/mixins/extendable-uploader.js
rename to app/assets/javascripts/discourse/app/lib/uppy/wrapper.js
index d5dab6cce9c..fd7cb99c88a 100644
--- a/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js
+++ b/app/assets/javascripts/discourse/app/lib/uppy/wrapper.js
@@ -1,16 +1,16 @@
-import Mixin from "@ember/object/mixin";
-import UploadDebugging from "discourse/mixins/upload-debugging";
+import { setOwner } from "@ember/owner";
+import UppyUploadDebugging from "./upload-debugging";
 
 /**
- * Use this mixin with any component that needs to upload files or images
- * with Uppy. This mixin makes it easier to tell Uppy to use certain uppy plugins
+ * Use this class whenever you need to upload files or images
+ * with Uppy. The class makes it easier to tell Uppy to use certain uppy plugins
  * as well as tracking all of the state of preprocessor plugins. For example,
  * you may have multiple preprocessors:
  *
  * - UppyMediaOptimization
  * - UppyChecksum
  *
- * Once installed with _useUploadPlugin(PluginClass, opts), we track the following
+ * Once installed with useUploadPlugin(PluginClass, opts), we track the following
  * status for every preprocessor plugin:
  *
  * - needProcessing - The total number of files that have been added to uppy that
@@ -21,28 +21,37 @@ import UploadDebugging from "discourse/mixins/upload-debugging";
  *                        which is determined by the preprocess-complete event.
  * - allComplete - Whether all files have completed the preprocessing for the plugin.
  *
- * There is a caveat - you must call _addNeedProcessing(data.fileIDs.length) when
+ * There is a caveat - you must call addNeedProcessing(data.fileIDs.length) when
  * handling the "upload" event with uppy, otherwise this mixin does not know how
  * many files need to be processed.
  *
  * If you need to do something else on progress or completion of preprocessors,
- * hook into the _onPreProcessProgress(callback) or _onPreProcessComplete(callback, allCompleteCallback)
- * functions. Note the _onPreProcessComplete function takes a second callback
+ * hook into the onPreProcessProgress(callback) or onPreProcessComplete(callback, allCompleteCallback)
+ * functions. Note the onPreProcessComplete function takes a second callback
  * that is fired only when _all_ of the files have been preprocessed for all
  * preprocessor plugins.
  *
  * A preprocessor is considered complete if the completeProcessing count is
  * equal to needProcessing, at which point the allComplete prop is set to true.
  * If all preprocessor plugins have allComplete set to true, then the allCompleteCallback
- * is called for _onPreProcessComplete.
+ * is called for onPreProcessComplete.
  *
- * To completely reset the preprocessor state for all plugins, call _resetPreProcessors.
+ * To completely reset the preprocessor state for all plugins, call resetPreProcessors.
  *
- * See ComposerUploadUppy for an example of a component using this mixin.
+ * See ComposerUploadUppy for an example of a component using this class.
  */
-export default Mixin.create(UploadDebugging, {
-  _useUploadPlugin(pluginClass, opts = {}) {
-    if (!this._uppyInstance) {
+export default class UppyWrapper {
+  debug;
+  uppyInstance;
+  #preProcessorStatus = {};
+
+  constructor(owner) {
+    setOwner(this, owner);
+    this.debug = new UppyUploadDebugging(owner);
+  }
+
+  useUploadPlugin(pluginClass, opts = {}) {
+    if (!this.uppyInstance) {
       return;
     }
 
@@ -61,7 +70,7 @@ export default Mixin.create(UploadDebugging, {
       );
     }
 
-    this._uppyInstance.use(
+    this.uppyInstance.use(
       pluginClass,
       Object.assign(opts, {
         id: pluginClass.pluginId,
@@ -70,9 +79,9 @@ export default Mixin.create(UploadDebugging, {
     );
 
     if (pluginClass.pluginType === "preprocessor") {
-      this._trackPreProcessorStatus(pluginClass.pluginId);
+      this.#trackPreProcessorStatus(pluginClass.pluginId);
     }
-  },
+  }
 
   // NOTE: This and _onPreProcessComplete will need to be tweaked
   // if we ever add support for "determinate" preprocessors for uppy, which
@@ -80,21 +89,19 @@ export default Mixin.create(UploadDebugging, {
   // state ("indeterminate").
   //
   // See: https://uppy.io/docs/writing-plugins/#Progress-events
-  _onPreProcessProgress(callback) {
-    this._uppyInstance.on("preprocess-progress", (file, progress, pluginId) => {
-      this._consoleDebug(
-        `[${pluginId}] processing file ${file.name} (${file.id})`
-      );
+  onPreProcessProgress(callback) {
+    this.uppyInstance.on("preprocess-progress", (file, progress, pluginId) => {
+      this.debug.log(`[${pluginId}] processing file ${file.name} (${file.id})`);
 
-      this._preProcessorStatus[pluginId].activeProcessing++;
+      this.#preProcessorStatus[pluginId].activeProcessing++;
 
       callback(file);
     });
-  },
+  }
 
-  _onPreProcessComplete(callback, allCompleteCallback = null) {
-    this._uppyInstance.on("preprocess-complete", (file, skipped, pluginId) => {
-      this._consoleDebug(
+  onPreProcessComplete(callback, allCompleteCallback = null) {
+    this.uppyInstance.on("preprocess-complete", (file, skipped, pluginId) => {
+      this.debug.log(
         `[${pluginId}] ${skipped ? "skipped" : "completed"} processing file ${
           file.name
         } (${file.id})`
@@ -102,63 +109,60 @@ export default Mixin.create(UploadDebugging, {
 
       callback(file);
 
-      this._completePreProcessing(pluginId, (allComplete) => {
+      this.#completePreProcessing(pluginId, (allComplete) => {
         if (allComplete) {
-          this._consoleDebug("[uppy] All upload preprocessors complete!");
+          this.debug.log("[uppy] All upload preprocessors complete!");
           if (allCompleteCallback) {
             allCompleteCallback();
           }
         }
       });
     });
-  },
+  }
 
-  _resetPreProcessors() {
-    this._eachPreProcessor((pluginId) => {
-      this._preProcessorStatus[pluginId] = {
+  resetPreProcessors() {
+    this.#eachPreProcessor((pluginId) => {
+      this.#preProcessorStatus[pluginId] = {
         needProcessing: 0,
         activeProcessing: 0,
         completeProcessing: 0,
         allComplete: false,
       };
     });
-  },
+  }
 
-  _trackPreProcessorStatus(pluginId) {
-    if (!this._preProcessorStatus) {
-      this._preProcessorStatus = {};
-    }
-    this._preProcessorStatus[pluginId] = {
+  #trackPreProcessorStatus(pluginId) {
+    this.#preProcessorStatus[pluginId] = {
       needProcessing: 0,
       activeProcessing: 0,
       completeProcessing: 0,
       allComplete: false,
     };
-  },
+  }
 
-  _addNeedProcessing(fileCount) {
-    this._eachPreProcessor((pluginName, status) => {
+  addNeedProcessing(fileCount) {
+    this.#eachPreProcessor((pluginName, status) => {
       status.needProcessing += fileCount;
       status.allComplete = false;
     });
-  },
+  }
 
-  _eachPreProcessor(cb) {
-    for (const [pluginId, status] of Object.entries(this._preProcessorStatus)) {
+  #eachPreProcessor(cb) {
+    for (const [pluginId, status] of Object.entries(this.#preProcessorStatus)) {
       cb(pluginId, status);
     }
-  },
+  }
 
-  _allPreprocessorsComplete() {
+  #allPreprocessorsComplete() {
     let completed = [];
-    this._eachPreProcessor((pluginId, status) => {
+    this.#eachPreProcessor((pluginId, status) => {
       completed.push(status.allComplete);
     });
     return completed.every(Boolean);
-  },
+  }
 
-  _completePreProcessing(pluginId, callback) {
-    const preProcessorStatus = this._preProcessorStatus[pluginId];
+  #completePreProcessing(pluginId, callback) {
+    const preProcessorStatus = this.#preProcessorStatus[pluginId];
     preProcessorStatus.activeProcessing--;
     preProcessorStatus.completeProcessing++;
 
@@ -170,11 +174,11 @@ export default Mixin.create(UploadDebugging, {
       preProcessorStatus.needProcessing = 0;
       preProcessorStatus.completeProcessing = 0;
 
-      if (this._allPreprocessorsComplete()) {
+      if (this.#allPreprocessorsComplete()) {
         callback(true);
       } else {
         callback(false);
       }
     }
-  },
-});
+  }
+}
diff --git a/app/assets/javascripts/discourse/app/mixins/composer-video-thumbnail-uppy.js b/app/assets/javascripts/discourse/app/mixins/composer-video-thumbnail-uppy.js
index 56f344543bc..874559c9f49 100644
--- a/app/assets/javascripts/discourse/app/mixins/composer-video-thumbnail-uppy.js
+++ b/app/assets/javascripts/discourse/app/mixins/composer-video-thumbnail-uppy.js
@@ -1,11 +1,14 @@
 import { tracked } from "@glimmer/tracking";
 import { warn } from "@ember/debug";
 import EmberObject from "@ember/object";
-import { setOwner } from "@ember/owner";
+import { getOwner, setOwner } from "@ember/owner";
 import { service } from "@ember/service";
 import Uppy from "@uppy/core";
+import XHRUpload from "@uppy/xhr-upload";
 import { isVideo } from "discourse/lib/uploads";
+import UppyS3Multipart from "discourse/lib/uppy/s3-multipart";
 import UppyUploadMixin from "discourse/mixins/uppy-upload";
+import getUrl from "discourse-common/helpers/get-url";
 import I18n from "discourse-i18n";
 
 // It is not ideal that this is a class extending a mixin, but in the case
@@ -32,11 +35,14 @@ export default class ComposerVideoThumbnailUppy extends EmberObject.extend(
   uploadTargetBound = false;
   useUploadPlaceholders = true;
   capabilities = null;
+  id = "composer-video";
+  uploadDone = () => {};
 
   constructor(owner) {
     super(...arguments);
     this.capabilities = owner.lookup("service:capabilities");
     setOwner(this, owner);
+    this.init();
   }
 
   generateVideoThumbnail(videoFile, uploadUrl, callback) {
@@ -113,13 +119,27 @@ export default class ComposerVideoThumbnailUppy extends EmberObject.extend(
             });
 
             if (this.siteSettings.enable_upload_debug_mode) {
-              this._instrumentUploadTimings();
+              this.uppyUpload.uppyWrapper.debug.instrumentUploadTimings(
+                this._uppyInstance
+              );
             }
 
             if (this.siteSettings.enable_direct_s3_uploads) {
-              this._useS3MultipartUploads();
+              new UppyS3Multipart(getOwner(this), {
+                uploadRootPath: this.uploadRootPath,
+                uppyWrapper: this.uppyUpload.uppyWrapper,
+                errorHandler: this._handleUploadError,
+              }).apply(this._uppyInstance);
             } else {
-              this._useXHRUploads();
+              this._uppyInstance.use(XHRUpload, {
+                endpoint:
+                  getUrl("/uploads") +
+                  ".json?client_id=" +
+                  this.messageBus?.clientId,
+                headers: () => ({
+                  "X-CSRF-Token": this.session.csrfToken,
+                }),
+              });
             }
 
             this._uppyInstance.on("upload", () => {
diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js
index ff2a0e958f0..8e8a28a02da 100644
--- a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js
+++ b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js
@@ -1,523 +1,131 @@
-import { warn } from "@ember/debug";
-import EmberObject from "@ember/object";
-import { or } from "@ember/object/computed";
+import { alias, or } from "@ember/object/computed";
+import { readOnly } from "@ember/object/lib/computed/computed_macros";
 import Mixin from "@ember/object/mixin";
-import { run } from "@ember/runloop";
-import { service } from "@ember/service";
-import AwsS3 from "@uppy/aws-s3";
-import Uppy from "@uppy/core";
-import DropTarget from "@uppy/drop-target";
-import XHRUpload from "@uppy/xhr-upload";
-import { ajax, updateCsrfToken } from "discourse/lib/ajax";
-import {
-  bindFileInputChangeListener,
-  displayErrorForUpload,
-  validateUploadedFile,
-} from "discourse/lib/uploads";
-import UppyChecksum from "discourse/lib/uppy-checksum-plugin";
-import UppyChunkedUploader from "discourse/lib/uppy-chunked-uploader-plugin";
-import ExtendableUploader from "discourse/mixins/extendable-uploader";
-import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart";
-import getUrl from "discourse-common/lib/get-url";
+import { getOwner } from "@ember/owner";
+import UppyUpload from "discourse/lib/uppy/uppy-upload";
 import { deepMerge } from "discourse-common/lib/object";
-import { bind, on } from "discourse-common/utils/decorators";
-import I18n from "discourse-i18n";
 
-export const HUGE_FILE_THRESHOLD_BYTES = 104_857_600; // 100MB
+export { HUGE_FILE_THRESHOLD_BYTES } from "discourse/lib/uppy/uppy-upload";
 
-export default Mixin.create(UppyS3Multipart, ExtendableUploader, {
-  dialog: service(),
+/**
+ * @deprecated
+ *
+ * This mixin exists only for backwards-compatibility.
+ *
+ * New implementations should use `lib/uppy/uppy-upload` directly.
+ */
+export default Mixin.create({
+  uppyUpload: null,
+
+  _uppyInstance: alias("uppyUpload.uppyWrapper.uppyInstance"),
+  uploadProgress: readOnly("uppyUpload.uploadProgress"),
+  inProgressUploads: readOnly("uppyUpload.inProgressUploads"),
+  filesAwaitingUpload: readOnly("uppyUpload.filesAwaitingUpload"),
+  cancellable: readOnly("uppyUpload.cancellable"),
+  uploadingOrProcessing: or("uppyUpload.uploading", "uppyUpload.processing"),
+  fileInputEl: alias("uppyUpload._fileInputEl"),
+  allowMultipleFiles: readOnly("uppyUpload.allowMultipleFiles"),
+
+  _addFiles: readOnly("uppyUpload.addFiles"),
+  _startUpload: readOnly("uppyUpload.startUpload"),
+
+  // Some places are two-way-binding these properties into parent components
+  // so we can't use computed properties as aliases.
+  // Instead, we have simple properties, with observers that update them when the underlying properties change.
   uploading: false,
-  uploadProgress: 0,
-  _uppyInstance: null,
-  autoStartUploads: true,
-  inProgressUploads: null,
-  id: null,
-  uploadRootPath: "/uploads",
-  fileInputSelector: ".hidden-upload-field",
-  autoFindInput: true,
+  processing: false,
 
-  uploadDone() {
-    warn("You should implement `uploadDone`", {
-      id: "discourse.upload.missing-upload-done",
-    });
-  },
+  init() {
+    this.uppyUpload = new UppyUpload(getOwner(this), configShim(this));
 
-  validateUploadedFilesOptions() {
-    return {};
-  },
-
-  /**
-   * Overridable for custom file validations, executed before uploading.
-   *
-   * @param {object} file
-   *
-   * @returns {boolean}
-   */
-  isUploadedFileAllowed() {
-    return true;
-  },
-
-  uploadingOrProcessing: or("uploading", "processing"),
-
-  @on("willDestroyElement")
-  _destroy() {
-    if (this.messageBus) {
-      this.messageBus.unsubscribe(`/uploads/${this.type}`);
-    }
-    this.fileInputEl?.removeEventListener(
-      "change",
-      this.fileInputEventListener
+    this.addObserver("uppyUpload.uploading", () =>
+      this.set("uploading", this.uppyUpload.uploading)
     );
-    this.appEvents.off(`upload-mixin:${this.id}:add-files`, this._addFiles);
-    this.appEvents.off(
-      `upload-mixin:${this.id}:cancel-upload`,
-      this._cancelSingleUpload
+    this.addObserver("uppyUpload.processing", () =>
+      this.set("processing", this.uppyUpload.processing)
     );
-    this._uppyInstance?.close();
-    this._uppyInstance = null;
+
+    this._super();
   },
 
-  @on("didInsertElement")
-  _initialize() {
-    if (this.autoFindInput) {
-      this.setProperties({
-        fileInputEl: this.element.querySelector(this.fileInputSelector),
-      });
-    } else if (!this.fileInputEl) {
-      return;
-    }
-    this.set("allowMultipleFiles", this.fileInputEl.multiple);
-    this.set("inProgressUploads", []);
-
-    this._bindFileInputChange();
-
-    if (!this.id) {
-      warn(
-        "uppy needs a unique id, pass one in to the component implementing this mixin",
-        {
-          id: "discourse.upload.missing-id",
-        }
+  didInsertElement() {
+    if (this.autoFindInput ?? true) {
+      this._fileInputEl = this.element.querySelector(
+        this.fileInputSelector || ".hidden-upload-field"
       );
-    }
-
-    this._uppyInstance = new Uppy({
-      id: this.id,
-      autoProceed: this.autoStartUploads,
-
-      // need to use upload_type because uppy overrides type with the
-      // actual file type
-      meta: deepMerge(
-        { upload_type: this.type },
-        this.additionalParams || {},
-        this.data || {}
-      ),
-
-      onBeforeFileAdded: (currentFile) => {
-        const validationOpts = deepMerge(
-          {
-            bypassNewUserRestriction: true,
-            user: this.currentUser,
-            siteSettings: this.siteSettings,
-            validateSize: true,
-          },
-          this.validateUploadedFilesOptions()
-        );
-        const isValid =
-          validateUploadedFile(currentFile, validationOpts) &&
-          this.isUploadedFileAllowed(currentFile);
-        this.setProperties({
-          uploadProgress: 0,
-          uploading: isValid && this.autoStartUploads,
-          filesAwaitingUpload: !this.autoStartUploads,
-          cancellable: isValid && this.autoStartUploads,
-        });
-        return isValid;
-      },
-
-      onBeforeUpload: (files) => {
-        let tooMany = false;
-        const fileCount = Object.keys(files).length;
-        const maxFiles =
-          this.maxFiles || this.siteSettings.simultaneous_uploads;
-
-        if (this.allowMultipleFiles) {
-          tooMany = maxFiles > 0 && fileCount > maxFiles;
-        } else {
-          tooMany = fileCount > 1;
-        }
-
-        if (tooMany) {
-          this.dialog.alert(
-            I18n.t("post.errors.too_many_dragged_and_dropped_files", {
-              count: this.allowMultipleFiles ? maxFiles : 1,
-            })
-          );
-          this._reset();
-          return false;
-        }
-
-        if (this._perFileData) {
-          Object.values(files).forEach((file) => {
-            deepMerge(file.meta, this._perFileData());
-          });
-        }
-      },
-    });
-
-    // DropTarget is a UI plugin, only preprocessors must call _useUploadPlugin
-    this._uppyInstance.use(DropTarget, this._uploadDropTargetOptions());
-
-    this._uppyInstance.on("progress", (progress) => {
-      if (this.isDestroying || this.isDestroyed) {
-        return;
-      }
-
-      this.set("uploadProgress", progress);
-    });
-
-    this._uppyInstance.on("upload", (data) => {
-      if (this.isDestroying || this.isDestroyed) {
-        return;
-      }
-
-      this._addNeedProcessing(data.fileIDs.length);
-      const files = data.fileIDs.map((fileId) =>
-        this._uppyInstance.getFile(fileId)
-      );
-      this.setProperties({
-        processing: true,
-        cancellable: false,
-      });
-      files.forEach((file) => {
-        // The inProgressUploads is meant to be used to display these uploads
-        // in a UI, and Ember will only update the array in the UI if pushObject
-        // is used to notify it.
-        this.inProgressUploads.pushObject(
-          EmberObject.create({
-            fileName: file.name,
-            id: file.id,
-            progress: 0,
-            extension: file.extension,
-            processing: false,
-          })
-        );
-        this._triggerInProgressUploadsEvent();
-      });
-    });
-
-    this._uppyInstance.on("upload-progress", (file, progress) => {
-      run(() => {
-        if (this.isDestroying || this.isDestroyed) {
-          return;
-        }
-
-        const upload = this.inProgressUploads.find((upl) => upl.id === file.id);
-        if (upload) {
-          const percentage = Math.round(
-            (progress.bytesUploaded / progress.bytesTotal) * 100
-          );
-          upload.set("progress", percentage);
-        }
-      });
-    });
-
-    this._uppyInstance.on("upload-success", (file, response) => {
-      if (this.usingS3Uploads) {
-        this.setProperties({ uploading: false, processing: true });
-        this._completeExternalUpload(file)
-          .then((completeResponse) => {
-            this._removeInProgressUpload(file.id);
-            this.appEvents.trigger(
-              `upload-mixin:${this.id}:upload-success`,
-              file.name,
-              completeResponse
-            );
-            this.uploadDone(
-              deepMerge(completeResponse, { file_name: file.name })
-            );
-
-            this._triggerInProgressUploadsEvent();
-            if (this.inProgressUploads.length === 0) {
-              this._allUploadsComplete();
-            }
-          })
-          .catch((errResponse) => {
-            displayErrorForUpload(errResponse, this.siteSettings, file.name);
-            this._triggerInProgressUploadsEvent();
-          });
-      } else {
-        this._removeInProgressUpload(file.id);
-        const upload = response?.body || {};
-        this.appEvents.trigger(
-          `upload-mixin:${this.id}:upload-success`,
-          file.name,
-          upload
-        );
-        this.uploadDone(deepMerge(upload, { file_name: file.name }));
-
-        this._triggerInProgressUploadsEvent();
-        if (this.inProgressUploads.length === 0) {
-          this._allUploadsComplete();
-        }
-      }
-    });
-
-    this._uppyInstance.on("upload-error", (file, error, response) => {
-      this._removeInProgressUpload(file.id);
-      displayErrorForUpload(response || error, this.siteSettings, file.name);
-      this._reset();
-    });
-
-    this._uppyInstance.on("file-removed", (file, reason) => {
-      run(() => {
-        // we handle the cancel-all event specifically, so no need
-        // to do anything here. this event is also fired when some files
-        // are handled by an upload handler
-        if (reason === "cancel-all") {
-          return;
-        }
-        this.appEvents.trigger(
-          `upload-mixin:${this.id}:upload-cancelled`,
-          file.id
-        );
-      });
-    });
-
-    if (this.siteSettings.enable_upload_debug_mode) {
-      this._instrumentUploadTimings();
-    }
-
-    // TODO (martin) preventDirectS3Uploads is necessary because some of
-    // the current upload mixin components, for example the emoji uploader,
-    // send the upload to custom endpoints that do fancy things in the rails
-    // controller with the upload or create additional data or records. we
-    // need a nice way to do this on complete-external-upload before we can
-    // allow these other uploaders to go direct to S3.
-    if (
-      this.siteSettings.enable_direct_s3_uploads &&
-      !this.preventDirectS3Uploads &&
-      !this.useChunkedUploads
-    ) {
-      if (this.useMultipartUploadsIfAvailable) {
-        this._useS3MultipartUploads();
-      } else {
-        this._useS3Uploads();
-      }
-    } else {
-      if (this.useChunkedUploads) {
-        this._useChunkedUploads();
-      } else {
-        this._useXHRUploads();
-      }
-    }
-
-    this._uppyInstance.on("cancel-all", () => {
-      this.appEvents.trigger(`upload-mixin:${this.id}:uploads-cancelled`);
-      if (!this.isDestroyed && !this.isDestroying) {
-        if (this.inProgressUploads.length) {
-          this.set("inProgressUploads", []);
-          this._triggerInProgressUploadsEvent();
-        }
-      }
-    });
-
-    this.appEvents.on(`upload-mixin:${this.id}:add-files`, this._addFiles);
-    this.appEvents.on(
-      `upload-mixin:${this.id}:cancel-upload`,
-      this._cancelSingleUpload
-    );
-    this._uppyReady();
-
-    // It is important that the UppyChecksum preprocessor is the last one to
-    // be added; the preprocessors are run in order and since other preprocessors
-    // may modify the file (e.g. the UppyMediaOptimization one), we need to
-    // checksum once we are sure the file data has "settled".
-    this._useUploadPlugin(UppyChecksum, { capabilities: this.capabilities });
-  },
-
-  _triggerInProgressUploadsEvent() {
-    this.onProgressUploadsChanged?.(this.inProgressUploads);
-    this.appEvents.trigger(
-      `upload-mixin:${this.id}:in-progress-uploads`,
-      this.inProgressUploads
-    );
-  },
-
-  // This should be overridden in a child component if you need to
-  // hook into uppy events and be sure that everything is already
-  // set up for _uppyInstance.
-  _uppyReady() {},
-
-  _startUpload() {
-    if (!this.filesAwaitingUpload) {
+    } else if (!this._fileInputEl) {
       return;
     }
-    if (!this._uppyInstance?.getFiles().length) {
-      return;
-    }
-    this.set("uploading", true);
-    return this._uppyInstance?.upload();
+    this.uppyUpload.setup(this._fileInputEl);
+    this._super();
   },
 
-  _useXHRUploads() {
-    this._uppyInstance.use(XHRUpload, {
-      endpoint: this._xhrUploadUrl(),
-      headers: () => ({
-        "X-CSRF-Token": this.session.csrfToken,
-      }),
-    });
-  },
-
-  _useChunkedUploads() {
-    this.set("usingChunkedUploads", true);
-    this._uppyInstance.use(UppyChunkedUploader, {
-      url: this._xhrUploadUrl(),
-      headers: {
-        "X-CSRF-Token": this.session.csrfToken,
-      },
-    });
-  },
-
-  _useS3Uploads() {
-    this.set("usingS3Uploads", true);
-    this._uppyInstance.use(AwsS3, {
-      getUploadParameters: (file) => {
-        const data = {
-          file_name: file.name,
-          file_size: file.size,
-          type: this.type,
-        };
-
-        // the sha1 checksum is set by the UppyChecksum plugin, except
-        // for in cases where the browser does not support the required
-        // crypto mechanisms or an error occurs. it is an additional layer
-        // of security, and not required.
-        if (file.meta.sha1_checksum) {
-          data.metadata = { "sha1-checksum": file.meta.sha1_checksum };
-        }
-
-        return ajax(`${this.uploadRootPath}/generate-presigned-put`, {
-          type: "POST",
-          data,
-        })
-          .then((response) => {
-            this._uppyInstance.setFileMeta(file.id, {
-              uniqueUploadIdentifier: response.unique_identifier,
-            });
-
-            return {
-              method: "put",
-              url: response.url,
-              headers: {
-                ...response.signed_headers,
-                "Content-Type": file.type,
-              },
-            };
-          })
-          .catch((errResponse) => {
-            displayErrorForUpload(errResponse, this.siteSettings, file.name);
-            this._reset();
-          });
-      },
-    });
-  },
-
-  _xhrUploadUrl() {
-    const uploadUrl = this.uploadUrl || this.uploadRootPath;
-    return getUrl(uploadUrl) + ".json?client_id=" + this.messageBus?.clientId;
-  },
-
-  _bindFileInputChange() {
-    this.fileInputEventListener = bindFileInputChangeListener(
-      this.fileInputEl,
-      this._addFiles
-    );
-  },
-
-  @bind
-  _cancelSingleUpload(data) {
-    this._uppyInstance.removeFile(data.fileId);
-    this._removeInProgressUpload(data.fileId);
-  },
-
-  @bind
-  async _addFiles(files, opts = {}) {
-    if (!this.session.csrfToken) {
-      await updateCsrfToken();
-    }
-
-    files = Array.isArray(files) ? files : [files];
-
-    try {
-      this._uppyInstance.addFiles(
-        files.map((file) => {
-          return {
-            source: this.id,
-            name: file.name,
-            type: file.type,
-            data: file,
-            meta: { pasted: opts.pasted },
-          };
-        })
-      );
-    } catch (err) {
-      warn(`error adding files to uppy: ${err}`, {
-        id: "discourse.upload.uppy-add-files-error",
-      });
-    }
-  },
-
-  _completeExternalUpload(file) {
-    return ajax(`${this.uploadRootPath}/complete-external-upload`, {
-      type: "POST",
-      data: deepMerge(
-        { unique_identifier: file.meta.uniqueUploadIdentifier },
-        this.additionalParams || {}
-      ),
-    });
-  },
-
-  _reset() {
-    this._uppyInstance?.cancelAll();
-    this.setProperties({
-      uploading: false,
-      processing: false,
-      cancellable: false,
-      uploadProgress: 0,
-      filesAwaitingUpload: false,
-    });
-    this.fileInputEl.value = "";
-  },
-
-  _removeInProgressUpload(fileId) {
-    if (this.isDestroyed || this.isDestroying) {
-      return;
-    }
-
-    this.set(
-      "inProgressUploads",
-      this.inProgressUploads.filter((upl) => upl.id !== fileId)
-    );
-    this._triggerInProgressUploadsEvent();
-  },
-
-  // target must be provided as a DOM element, however the
-  // onDragOver and onDragLeave callbacks can also be provided.
-  // it is advisable to debounce/add a setTimeout timer when
-  // doing anything in these callbacks to avoid jumping. uppy
-  // also adds a .uppy-is-drag-over class to the target element by
-  // default onDragOver and removes it onDragLeave
-  _uploadDropTargetOptions() {
-    return { target: this.element };
-  },
-
-  _allUploadsComplete() {
-    if (this.isDestroying || this.isDestroyed) {
-      return;
-    }
-
-    this.appEvents.trigger(`upload-mixin:${this.id}:all-uploads-complete`);
-    this._reset();
+  willDestroyElement() {
+    this.uppyUpload.teardown();
+    this._super();
   },
 });
+
+/**
+ * Given a component which was written for the old mixin interface,
+ * this function will generate a config object which is compatible
+ * with the new `lib/uppy/uppy-upload` class.
+ */
+function configShim(component) {
+  return {
+    get autoStartUploads() {
+      return component.autoStartUploads || true;
+    },
+    get id() {
+      return component.id;
+    },
+    get type() {
+      return component.type;
+    },
+    get uploadRootPath() {
+      return component.uploadRootPath || "/uploads";
+    },
+    get uploadDone() {
+      return component.uploadDone.bind(component);
+    },
+    get validateUploadedFilesOptions() {
+      return component.validateUploadedFilesOptions?.() || {};
+    },
+    get additionalParams() {
+      return deepMerge({}, component.additionalParams, component.data);
+    },
+    get maxFiles() {
+      return component.maxFiles;
+    },
+    get uploadDropTargetOptions() {
+      return (
+        component.uploadDropTargetOptions?.() || { target: component.element }
+      );
+    },
+    get preventDirectS3Uploads() {
+      return component.preventDirectS3Uploads ?? false;
+    },
+    get useChunkedUploads() {
+      return component.useChunkedUploads ?? false;
+    },
+    get useMultipartUploadsIfAvailable() {
+      return component.useMultipartUploadsIfAvailable ?? false;
+    },
+    get uploadError() {
+      return component._handleUploadError?.bind(component);
+    },
+    get uppyReady() {
+      return component._uppyReady?.bind(component);
+    },
+    onProgressUploadsChanged() {
+      component.notifyPropertyChange("inProgressUploads"); // because TrackedArray isn't perfectly compatible with legacy computed properties
+      return component.onProgressUploadsChanged?.call(component, ...arguments);
+    },
+    get uploadUrl() {
+      return component.uploadUrl;
+    },
+    get perFileData() {
+      return component._perFileData?.bind(component);
+    },
+  };
+}
diff --git a/app/assets/javascripts/discourse/app/services/composer.js b/app/assets/javascripts/discourse/app/services/composer.js
index 14b4ef517fe..5aa0bc4c7a0 100644
--- a/app/assets/javascripts/discourse/app/services/composer.js
+++ b/app/assets/javascripts/discourse/app/services/composer.js
@@ -121,6 +121,8 @@ export default class ComposerService extends Service {
   lastValidatedAt = null;
   isUploading = false;
   isProcessingUpload = false;
+  isCancellable;
+  uploadProgress;
   topic = null;
   linkLookup = null;
   showPreview = true;
@@ -639,7 +641,7 @@ export default class ComposerService extends Service {
   @action
   cancelUpload(event) {
     event?.preventDefault();
-    this.set("model.uploadCancelled", true);
+    this.appEvents.trigger("composer:cancel-upload");
   }
 
   @action
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
index 39dcefe5401..fd1f17c65c3 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js
@@ -88,21 +88,21 @@ export default class ChatComposerUploads extends Component.extend(
 
   _uppyReady() {
     if (this.siteSettings.composer_media_optimization_image_enabled) {
-      this._useUploadPlugin(UppyMediaOptimization, {
+      this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
         optimizeFn: (data, opts) =>
           this.mediaOptimizationWorker.optimizeImage(data, opts),
         runParallel: !this.site.isMobileDevice,
       });
     }
 
-    this._onPreProcessProgress((file) => {
+    this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
       const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
       if (!inProgressUpload?.processing) {
         inProgressUpload?.set("processing", true);
       }
     });
 
-    this._onPreProcessComplete((file) => {
+    this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
       const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
       inProgressUpload?.set("processing", false);
     });