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.
This commit is contained in:
parent
6a7bac7694
commit
06d32a8a89
|
@ -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({
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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`
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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", () => {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue