diff --git a/app/assets/javascripts/discourse/app/components/pick-files-button.js b/app/assets/javascripts/discourse/app/components/pick-files-button.js
new file mode 100644
index 00000000000..b4752881d22
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/pick-files-button.js
@@ -0,0 +1,106 @@
+import Component from "@ember/component";
+import { action } from "@ember/object";
+import { empty } from "@ember/object/computed";
+import { bind, default as computed } from "discourse-common/utils/decorators";
+import I18n from "I18n";
+
+export default Component.extend({
+ classNames: ["pick-files-button"],
+ acceptedFileTypes: null,
+ acceptAnyFile: empty("acceptedFileTypes"),
+
+ didInsertElement() {
+ this._super(...arguments);
+ const fileInput = this.element.querySelector("input");
+ this.set("fileInput", fileInput);
+ fileInput.addEventListener("change", this.onChange, false);
+ },
+
+ willDestroyElement() {
+ this._super(...arguments);
+ this.fileInput.removeEventListener("change", this.onChange);
+ },
+
+ @bind
+ onChange() {
+ const files = this.fileInput.files;
+ this._filesPicked(files);
+ },
+
+ @computed
+ acceptedFileTypesString() {
+ if (!this.acceptedFileTypes) {
+ return null;
+ }
+
+ return this.acceptedFileTypes.join(",");
+ },
+
+ @computed
+ acceptedExtensions() {
+ if (!this.acceptedFileTypes) {
+ return null;
+ }
+
+ return this.acceptedFileTypes
+ .filter((type) => type.startsWith("."))
+ .map((type) => type.substring(1));
+ },
+
+ @computed
+ acceptedMimeTypes() {
+ if (!this.acceptedFileTypes) {
+ return null;
+ }
+
+ return this.acceptedFileTypes.filter((type) => !type.startsWith("."));
+ },
+
+ @action
+ openSystemFilePicker() {
+ this.fileInput.click();
+ },
+
+ _filesPicked(files) {
+ if (!files || !files.length) {
+ return;
+ }
+
+ if (!this._haveAcceptedTypes(files)) {
+ const message = I18n.t("pick_files_button.unsupported_file_picked", {
+ types: this.acceptedFileTypesString,
+ });
+ bootbox.alert(message);
+ return;
+ }
+ this.onFilesPicked(files);
+ },
+
+ _haveAcceptedTypes(files) {
+ for (const file of files) {
+ if (
+ !(this._hasAcceptedExtension(file) && this._hasAcceptedMimeType(file))
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ _hasAcceptedExtension(file) {
+ const extension = this._fileExtension(file.name);
+ return (
+ !this.acceptedExtensions || this.acceptedExtensions.includes(extension)
+ );
+ },
+
+ _hasAcceptedMimeType(file) {
+ return (
+ !this.acceptedMimeTypes || this.acceptedMimeTypes.includes(file.type)
+ );
+ },
+
+ _fileExtension(fileName) {
+ return fileName.split(".").pop();
+ },
+});
diff --git a/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs b/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs
new file mode 100644
index 00000000000..409a3becbd7
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/pick-files-button.hbs
@@ -0,0 +1,6 @@
+{{d-button action=(action "openSystemFilePicker") label=label icon=icon}}
+{{#if acceptAnyFile}}
+
+{{else}}
+
+{{/if}}
diff --git a/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js b/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js
new file mode 100644
index 00000000000..4c82e9d510d
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/pick-files-button-tests.js
@@ -0,0 +1,78 @@
+import componentTest, {
+ setupRenderingTest,
+} from "discourse/tests/helpers/component-test";
+import { discourseModule } from "discourse/tests/helpers/qunit-helpers";
+import hbs from "htmlbars-inline-precompile";
+import { triggerEvent } from "@ember/test-helpers";
+import sinon from "sinon";
+
+function createBlob(mimeType, extension) {
+ const blob = new Blob(["content"], {
+ type: mimeType,
+ });
+ blob.name = `filename${extension}`;
+ return blob;
+}
+
+discourseModule(
+ "Integration | Component | pick-files-button",
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ componentTest(
+ "it shows alert if a file with an unsupported extension was chosen",
+ {
+ skip: true,
+ template: hbs`
+ {{pick-files-button
+ acceptedFileTypes=this.acceptedFileTypes
+ onFilesChosen=this.onFilesChosen}}`,
+
+ beforeEach() {
+ const expectedExtension = ".json";
+ this.set("acceptedFileTypes", [expectedExtension]);
+ this.set("onFilesChosen", () => {});
+ },
+
+ async test(assert) {
+ sinon.stub(bootbox, "alert");
+
+ const wrongExtension = ".txt";
+ const file = createBlob("text/json", wrongExtension);
+
+ await triggerEvent("input#file-input", "change", { files: [file] });
+
+ assert.ok(bootbox.alert.calledOnce);
+ },
+ }
+ );
+
+ componentTest(
+ "it shows alert if a file with an unsupported MIME type was chosen",
+ {
+ skip: true,
+ template: hbs`
+ {{pick-files-button
+ acceptedFileTypes=this.acceptedFileTypes
+ onFilesChosen=this.onFilesChosen}}`,
+
+ beforeEach() {
+ const expectedMimeType = "text/json";
+ this.set("acceptedFileTypes", [expectedMimeType]);
+ this.set("onFilesChosen", () => {});
+ },
+
+ async test(assert) {
+ sinon.stub(bootbox, "alert");
+
+ const wrongMimeType = "text/plain";
+ const file = createBlob(wrongMimeType, ".json");
+
+ await triggerEvent("input#file-input", "change", { files: [file] });
+
+ assert.ok(bootbox.alert.calledOnce);
+ },
+ }
+ );
+ }
+);
diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss
index 57eb631bd2d..02745f3cf00 100644
--- a/app/assets/stylesheets/common/components/_index.scss
+++ b/app/assets/stylesheets/common/components/_index.scss
@@ -17,6 +17,7 @@
@import "ignored-user-list";
@import "keyboard_shortcuts";
@import "navs";
+@import "pick-files-button";
@import "relative-time-picker";
@import "share-and-invite-modal";
@import "svg";
diff --git a/app/assets/stylesheets/common/components/pick-files-button.scss b/app/assets/stylesheets/common/components/pick-files-button.scss
new file mode 100644
index 00000000000..c9945168f08
--- /dev/null
+++ b/app/assets/stylesheets/common/components/pick-files-button.scss
@@ -0,0 +1,5 @@
+.pick-files-button {
+ input[type="file"] {
+ display: none;
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 96af2e71dd9..86ec2b065c4 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3784,6 +3784,9 @@ en:
leader: "leader"
detailed_name: "%{level}: %{name}"
+ pick_files_button:
+ unsupported_file_picked: "You have picked an unsupported file. Supported file types – %{types}."
+
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "type to filter..."