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:
David Taylor 2024-10-16 11:15:19 +01:00 committed by GitHub
parent 6a7bac7694
commit 06d32a8a89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1077 additions and 900 deletions

View File

@ -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({

View File

@ -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.

View File

@ -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"
);
}
}

View File

@ -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);
});
},
});
}
}

View File

@ -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`
)
);
});
},
});
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}
},
});
}
}

View File

@ -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", () => {

View File

@ -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);
},
};
}

View File

@ -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

View File

@ -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);
});