From 06d32a8a894480673dce31241d81f7aeee50d04e Mon Sep 17 00:00:00 2001 From: David Taylor 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); });