diff --git a/Gemfile b/Gemfile index 80d264469e..efd3fd5c34 100644 --- a/Gemfile +++ b/Gemfile @@ -175,8 +175,16 @@ group :development do gem 'better_errors', platform: :mri, require: !!ENV['BETTER_ERRORS'] gem 'binding_of_caller' gem 'yaml-lint' +end + +if ENV["ALLOW_DEV_POPULATE"] == "1" gem 'discourse_dev_assets' gem 'faker', "~> 2.16" +else + group :development do + gem 'discourse_dev_assets' + gem 'faker', "~> 2.16" + end end # this is an optional gem, it provides a high performance replacement diff --git a/Gemfile.lock b/Gemfile.lock index a26a45e88a..5aad34b4e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,7 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.84.0) + excon (0.85.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) @@ -200,7 +200,7 @@ GEM libv8-node (15.14.0.1-x86_64-darwin-19) libv8-node (15.14.0.1-x86_64-darwin-20) libv8-node (15.14.0.1-x86_64-linux) - listen (3.5.1) + listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) literate_randomizer (0.4.0) @@ -407,7 +407,7 @@ GEM ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) - ruby2_keywords (0.0.4) + ruby2_keywords (0.0.5) rubyzip (2.3.2) sanitize (5.2.3) crass (~> 1.0.2) diff --git a/README.md b/README.md index 1bb39f2f5c..7aeac47e06 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Discourse supports the **latest, stable releases** of all major browsers and pla - [Redis](https://redis.io/) — We use Redis as a cache and for transient data. - [BrowserStack](https://www.browserstack.com/) — We use BrowserStack to test on real devices and browsers. -Plus *lots* of Ruby Gems, a complete list of which is at [/master/Gemfile](https://github.com/discourse/discourse/blob/master/Gemfile). +Plus *lots* of Ruby Gems, a complete list of which is at [/main/Gemfile](https://github.com/discourse/discourse/blob/main/Gemfile). ## Contributing @@ -80,7 +80,7 @@ Before contributing to Discourse: 1. Please read the complete mission statements on [**discourse.org**](https://www.discourse.org). Yes we actually believe this stuff; you should too. 2. Read and sign the [**Electronic Discourse Forums Contribution License Agreement**](https://www.discourse.org/cla). 3. Dig into [**CONTRIBUTING.MD**](CONTRIBUTING.md), which covers submitting bugs, requesting new features, preparing your code for a pull request, etc. -4. Always strive to collaborate [with mutual respect](https://github.com/discourse/discourse/blob/master/docs/code-of-conduct.md). +4. Always strive to collaborate [with mutual respect](https://github.com/discourse/discourse/blob/main/docs/code-of-conduct.md). 5. Not sure what to work on? [**We've got some ideas.**](https://meta.discourse.org/t/so-you-want-to-help-out-with-discourse/3823) @@ -88,7 +88,7 @@ We look forward to seeing your pull requests! ## Security -We take security very seriously at Discourse; all our code is 100% open source and peer reviewed. Please read [our security guide](https://github.com/discourse/discourse/blob/master/docs/SECURITY.md) for an overview of security measures in Discourse, or if you wish to report a security issue. +We take security very seriously at Discourse; all our code is 100% open source and peer reviewed. Please read [our security guide](https://github.com/discourse/discourse/blob/main/docs/SECURITY.md) for an overview of security measures in Discourse, or if you wish to report a security issue. ## The Discourse Team diff --git a/app/assets/javascripts/admin/addon/components/color-input.js b/app/assets/javascripts/admin/addon/components/color-input.js index c563fedc94..91aeaec6e0 100644 --- a/app/assets/javascripts/admin/addon/components/color-input.js +++ b/app/assets/javascripts/admin/addon/components/color-input.js @@ -22,9 +22,21 @@ export default Component.extend({ return this.onlyHex ? 6 : null; }), + normalize(color) { + if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) { + if (!color.startsWith("#")) { + color = "#" + color; + } + } + + return color; + }, + @action onHexInput(color) { - this.attrs.onChangeColor && this.attrs.onChangeColor(color || ""); + if (this.attrs.onChangeColor) { + this.attrs.onChangeColor(this.normalize(color || "")); + } }, @observes("hexValue", "brightnessValue", "valid") @@ -32,7 +44,9 @@ export default Component.extend({ const hex = this.hexValue; let text = this.element.querySelector("input.hex-input"); - this.attrs.onChangeColor && this.attrs.onChangeColor(hex); + if (this.attrs.onChangeColor) { + this.attrs.onChangeColor(this.normalize(hex)); + } if (this.valid) { this.styleSelection && diff --git a/app/assets/javascripts/admin/addon/components/themes-list.js b/app/assets/javascripts/admin/addon/components/themes-list.js index 715ca9ccee..ca1d872f46 100644 --- a/app/assets/javascripts/admin/addon/components/themes-list.js +++ b/app/assets/javascripts/admin/addon/components/themes-list.js @@ -1,8 +1,9 @@ import { COMPONENTS, THEMES } from "admin/models/theme"; -import { equal, gt } from "@ember/object/computed"; +import { equal, gt, gte } from "@ember/object/computed"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; export default Component.extend({ router: service(), @@ -10,10 +11,12 @@ export default Component.extend({ COMPONENTS, classNames: ["themes-list"], + filterTerm: null, hasThemes: gt("themesList.length", 0), hasActiveThemes: gt("activeThemes.length", 0), hasInactiveThemes: gt("inactiveThemes.length", 0), + showFilter: gte("themesList.length", 10), themesTabActive: equal("currentTab", THEMES), componentsTabActive: equal("currentTab", COMPONENTS), @@ -31,28 +34,36 @@ export default Component.extend({ "themesList", "currentTab", "themesList.@each.user_selectable", - "themesList.@each.default" + "themesList.@each.default", + "filterTerm" ) inactiveThemes(themes) { + let results; if (this.componentsTabActive) { - return themes.filter((theme) => theme.get("parent_themes.length") <= 0); + results = themes.filter( + (theme) => theme.get("parent_themes.length") <= 0 + ); + } else { + results = themes.filter( + (theme) => !theme.get("user_selectable") && !theme.get("default") + ); } - return themes.filter( - (theme) => !theme.get("user_selectable") && !theme.get("default") - ); + return this._filterThemes(results, this.filterTerm); }, @discourseComputed( "themesList", "currentTab", "themesList.@each.user_selectable", - "themesList.@each.default" + "themesList.@each.default", + "filterTerm" ) activeThemes(themes) { + let results; if (this.componentsTabActive) { - return themes.filter((theme) => theme.get("parent_themes.length") > 0); + results = themes.filter((theme) => theme.get("parent_themes.length") > 0); } else { - return themes + results = themes .filter((theme) => theme.get("user_selectable") || theme.get("default")) .sort((a, b) => { if (a.get("default") && !b.get("default")) { @@ -66,16 +77,29 @@ export default Component.extend({ .localeCompare(b.get("name").toLowerCase()); }); } + return this._filterThemes(results, this.filterTerm); }, - actions: { - changeView(newTab) { - if (newTab !== this.currentTab) { - this.set("currentTab", newTab); + _filterThemes(themes, term) { + term = term?.trim()?.toLowerCase(); + if (!term) { + return themes; + } + return themes.filter(({ name }) => name.toLowerCase().includes(term)); + }, + + @action + changeView(newTab) { + if (newTab !== this.currentTab) { + this.set("currentTab", newTab); + if (!this.showFilter) { + this.set("filterTerm", null); } - }, - navigateToTheme(theme) { - this.router.transitionTo("adminCustomizeThemes.show", theme); - }, + } + }, + + @action + navigateToTheme(theme) { + this.router.transitionTo("adminCustomizeThemes.show", theme); }, }); diff --git a/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs b/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs index a02fb2f2ce..ab342b1b93 100644 --- a/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/themes-list.hbs @@ -15,6 +15,17 @@
+ {{#if showFilter}} +
+ {{input + class="filter-input" + placeholder=(i18n "admin.customize.theme.filter_placeholder") + autocomplete="discourse" + value=(mut filterTerm) + }} + {{d-icon "search"}} +
+ {{/if}} {{#if hasThemes}} {{#if hasActiveThemes}} {{#each activeThemes as |theme|}} diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index ae3addd81a..ce73e3b597 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -652,7 +652,6 @@ export default Component.extend({ this.setProperties({ uploadProgress: 0, isUploading: false, - isProcessingUpload: false, isCancellable: false, }); } @@ -683,6 +682,14 @@ export default Component.extend({ }); $element + .on("fileuploadprocessstart", () => { + this.setProperties({ + uploadProgress: 0, + isUploading: true, + isProcessingUpload: true, + isCancellable: false, + }); + }) .on("fileuploadprocess", (e, data) => { this.appEvents.trigger( "composer:insert-text", @@ -690,12 +697,6 @@ export default Component.extend({ filename: data.files[data.index].name, })}]()\n` ); - this.setProperties({ - uploadProgress: 0, - isUploading: true, - isProcessingUpload: true, - isCancellable: false, - }); }) .on("fileuploadprocessalways", (e, data) => { this.appEvents.trigger( @@ -705,6 +706,8 @@ export default Component.extend({ })}]()\n`, "" ); + }) + .on("fileuploadprocessstop", () => { this.setProperties({ uploadProgress: 0, isUploading: false, diff --git a/app/assets/javascripts/discourse/app/components/directory-table.js b/app/assets/javascripts/discourse/app/components/directory-table.js index e92b8846e1..08cb163562 100644 --- a/app/assets/javascripts/discourse/app/components/directory-table.js +++ b/app/assets/javascripts/discourse/app/components/directory-table.js @@ -79,7 +79,7 @@ export default Component.extend({ } }, - willDestoryElement() { + willDestroyElement() { this._tableContainer.removeEventListener("scroll", this.onBottomScroll); this._topHorizontalScrollBar.removeEventListener( "scroll", diff --git a/app/assets/javascripts/discourse/app/components/edit-category-settings.js b/app/assets/javascripts/discourse/app/components/edit-category-settings.js index bfcfa19cd5..93947368f3 100644 --- a/app/assets/javascripts/discourse/app/components/edit-category-settings.js +++ b/app/assets/javascripts/discourse/app/components/edit-category-settings.js @@ -138,4 +138,10 @@ export default buildCategoryPanel("settings", { let hours = minutes ? minutes / 60 : null; this.set("category.auto_close_hours", hours); }, + + @action + onDefaultSlowModeDurationChange(minutes) { + let seconds = minutes ? minutes * 60 : null; + this.set("category.default_slow_mode_seconds", seconds); + }, }); diff --git a/app/assets/javascripts/discourse/app/components/group-membership-button.js b/app/assets/javascripts/discourse/app/components/group-membership-button.js index 46329bba2d..4c8239aedc 100644 --- a/app/assets/javascripts/discourse/app/components/group-membership-button.js +++ b/app/assets/javascripts/discourse/app/components/group-membership-button.js @@ -37,7 +37,7 @@ export default Component.extend({ removeFromGroup() { const model = this.model; model - .removeMember(this.currentUser) + .leave() .then(() => { model.set("is_group_user", false); this.appEvents.trigger("group:leave", model); @@ -50,13 +50,13 @@ export default Component.extend({ joinGroup() { if (this.currentUser) { this.set("updatingMembership", true); - const model = this.model; + const group = this.model; - model - .addMembers(this.currentUser.get("username")) + group + .join() .then(() => { - model.set("is_group_user", true); - this.appEvents.trigger("group:join", model); + group.set("is_group_user", true); + this.appEvents.trigger("group:join", group); }) .catch(popupAjaxError) .finally(() => { diff --git a/app/assets/javascripts/discourse/app/components/groups-form-profile-fields.js b/app/assets/javascripts/discourse/app/components/groups-form-profile-fields.js index d2a439ed54..43fb8c4d70 100644 --- a/app/assets/javascripts/discourse/app/components/groups-form-profile-fields.js +++ b/app/assets/javascripts/discourse/app/components/groups-form-profile-fields.js @@ -57,52 +57,50 @@ export default Component.extend({ ); } - this.checkGroupName(); + this.checkGroupNameDebounced(); return this._failedInputValidation( I18n.t("admin.groups.new.name.checking") ); }, - checkGroupName() { - discourseDebounce( - this, - function () { - if (isEmpty(this.nameInput)) { - return; + checkGroupNameDebounced() { + discourseDebounce(this, this._checkGroupName, 500); + }, + + _checkGroupName() { + if (isEmpty(this.nameInput)) { + return; + } + + Group.checkName(this.nameInput) + .then((response) => { + const validationName = "uniqueNameValidation"; + + if (response.available) { + this.set( + validationName, + EmberObject.create({ + ok: true, + reason: I18n.t("admin.groups.new.name.available"), + }) + ); + + this.set("disableSave", false); + this.set("model.name", this.nameInput); + } else { + let reason; + + if (response.errors) { + reason = response.errors.join(" "); + } else { + reason = I18n.t("admin.groups.new.name.not_available"); + } + + this.set(validationName, this._failedInputValidation(reason)); } - - Group.checkName(this.nameInput) - .then((response) => { - const validationName = "uniqueNameValidation"; - - if (response.available) { - this.set( - validationName, - EmberObject.create({ - ok: true, - reason: I18n.t("admin.groups.new.name.available"), - }) - ); - - this.set("disableSave", false); - this.set("model.name", this.nameInput); - } else { - let reason; - - if (response.errors) { - reason = response.errors.join(" "); - } else { - reason = I18n.t("admin.groups.new.name.not_available"); - } - - this.set(validationName, this._failedInputValidation(reason)); - } - }) - .catch(popupAjaxError); - }, - 500 - ); + }) + .catch(popupAjaxError); }, _failedInputValidation(reason) { 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 0000000000..b4752881d2 --- /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/components/reviewable-item.js b/app/assets/javascripts/discourse/app/components/reviewable-item.js index 23071cac96..d7173eb3ec 100644 --- a/app/assets/javascripts/discourse/app/components/reviewable-item.js +++ b/app/assets/javascripts/discourse/app/components/reviewable-item.js @@ -29,14 +29,14 @@ export default Component.extend({ @discourseComputed( "reviewable.type", - "reviewable.stale", + "reviewable.last_performing_username", "siteSettings.blur_tl0_flagged_posts_media", "reviewable.target_created_by_trust_level" ) - customClasses(type, stale, blurEnabled, trustLevel) { + customClasses(type, lastPerformingUsername, blurEnabled, trustLevel) { let classes = type.dasherize(); - if (stale) { + if (lastPerformingUsername) { classes = `${classes} reviewable-stale`; } diff --git a/app/assets/javascripts/discourse/app/components/share-popup.js b/app/assets/javascripts/discourse/app/components/share-popup.js index a2d9decc13..85e815ce50 100644 --- a/app/assets/javascripts/discourse/app/components/share-popup.js +++ b/app/assets/javascripts/discourse/app/components/share-popup.js @@ -49,8 +49,11 @@ export default Component.extend({ if (this.element) { const linkInput = this.element.querySelector("#share-link input"); linkInput.value = this.link; - linkInput.setSelectionRange(0, this.link.length); - linkInput.focus(); + if (!this.site.mobileView) { + // if the input is auto-focused on mobile, iOS requires two taps of the copy button + linkInput.setSelectionRange(0, this.link.length); + linkInput.focus(); + } } }, 200); }, diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index d9895801fa..9ac49c9cfe 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -218,7 +218,6 @@ const SiteHeaderComponent = MountWidget.extend( this.dispatch("notifications:changed", "user-notifications"); this.dispatch("header:keyboard-trigger", "header"); - this.dispatch("search-autocomplete:after-complete", "search-term"); this.dispatch("user-menu:navigation", "user-menu"); this.appEvents.on("dom:clean", this, "_cleanDom"); diff --git a/app/assets/javascripts/discourse/app/components/topic-navigation.js b/app/assets/javascripts/discourse/app/components/topic-navigation.js index 93d39a2660..77d1dee893 100644 --- a/app/assets/javascripts/discourse/app/components/topic-navigation.js +++ b/app/assets/javascripts/discourse/app/components/topic-navigation.js @@ -5,7 +5,7 @@ import PanEvents, { import Component from "@ember/component"; import EmberObject from "@ember/object"; import discourseDebounce from "discourse-common/lib/debounce"; -import { later } from "@ember/runloop"; +import { later, next } from "@ember/runloop"; import { observes } from "discourse-common/utils/decorators"; import showModal from "discourse/lib/show-modal"; @@ -16,12 +16,23 @@ export default Component.extend(PanEvents, { composerOpen: null, info: null, isPanning: false, + canRender: true, + _lastTopicId: null, init() { this._super(...arguments); this.set("info", EmberObject.create()); }, + didUpdateAttrs() { + this._super(...arguments); + if (this._lastTopicId !== this.topic.id) { + this._lastTopicId = this.topic.id; + this.set("canRender", false); + next(() => this.set("canRender", true)); + } + }, + _performCheckSize() { if (!this.element || this.isDestroying || this.isDestroyed) { return; @@ -180,6 +191,8 @@ export default Component.extend(PanEvents, { didInsertElement() { this._super(...arguments); + this._lastTopicId = this.topic.id; + this.appEvents .on("topic:current-post-scrolled", this, this._topicScrolled) .on("topic:jump-to-post", this, this._collapseFullscreen) diff --git a/app/assets/javascripts/discourse/app/components/topic-status.js b/app/assets/javascripts/discourse/app/components/topic-status.js index a370849c8c..0cea69125b 100644 --- a/app/assets/javascripts/discourse/app/components/topic-status.js +++ b/app/assets/javascripts/discourse/app/components/topic-status.js @@ -11,9 +11,8 @@ export default Component.extend({ if (this.canAct && $(e.target).hasClass("d-icon-thumbtack")) { const topic = this.topic; topic.get("pinned") ? topic.clearPin() : topic.rePin(); + return false; } - - return false; }, @discourseComputed("disableActions") diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 9443a74907..e9fa6a2c80 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -663,7 +663,7 @@ export default Controller.extend({ }, }, - disableSubmit: or("model.loading", "isUploading"), + disableSubmit: or("model.loading", "isUploading", "isProcessingUpload"), save(force, options = {}) { if (this.disableSubmit) { diff --git a/app/assets/javascripts/discourse/app/controllers/group-requests.js b/app/assets/javascripts/discourse/app/controllers/group-requests.js index 73da360192..9cb4dcf3a6 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-requests.js +++ b/app/assets/javascripts/discourse/app/controllers/group-requests.js @@ -7,10 +7,10 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend({ application: controller(), - queryParams: ["order", "desc", "filter"], + queryParams: ["order", "asc", "filter"], order: "", - desc: null, + asc: null, filter: null, filterInput: null, @@ -27,7 +27,7 @@ export default Controller.extend({ ); }, - @observes("order", "desc", "filter") + @observes("order", "asc", "filter") _filtersChanged() { this.findRequesters(true); }, @@ -57,9 +57,9 @@ export default Controller.extend({ }); }, - @discourseComputed("order", "desc", "filter") - memberParams(order, desc, filter) { - return { order, desc, filter }; + @discourseComputed("order", "asc", "filter") + memberParams(order, asc, filter) { + return { order, asc, filter }; }, @discourseComputed("model.requesters.[]") diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js index 278b6c0109..0a6574e9ff 100644 --- a/app/assets/javascripts/discourse/app/controllers/invites-show.js +++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js @@ -31,6 +31,7 @@ export default Controller.extend( accountEmail: alias("email"), hiddenEmail: alias("model.hidden_email"), emailVerifiedByLink: alias("model.email_verified_by_link"), + differentExternalEmail: alias("model.different_external_email"), accountUsername: alias("model.username"), passwordRequired: notEmpty("accountPassword"), successMessage: null, @@ -130,7 +131,8 @@ export default Controller.extend( "authOptions.email", "authOptions.email_valid", "hiddenEmail", - "emailVerifiedByLink" + "emailVerifiedByLink", + "differentExternalEmail" ) emailValidation( email, @@ -138,9 +140,10 @@ export default Controller.extend( externalAuthEmail, externalAuthEmailValid, hiddenEmail, - emailVerifiedByLink + emailVerifiedByLink, + differentExternalEmail ) { - if (hiddenEmail) { + if (hiddenEmail && !differentExternalEmail) { return EmberObject.create({ ok: true, reason: I18n.t("user.email.ok"), diff --git a/app/assets/javascripts/discourse/app/controllers/preferences.js b/app/assets/javascripts/discourse/app/controllers/preferences.js index 1c0ee0801d..fa4ba1ee7d 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences.js @@ -1,2 +1,3 @@ import Controller from "@ember/controller"; + export default Controller.extend({}); diff --git a/app/assets/javascripts/discourse/app/initializers/live-development.js b/app/assets/javascripts/discourse/app/initializers/live-development.js index bf92d0e2c2..7b0c207082 100644 --- a/app/assets/javascripts/discourse/app/initializers/live-development.js +++ b/app/assets/javascripts/discourse/app/initializers/live-development.js @@ -60,12 +60,22 @@ export default { // Refresh if necessary document.location.reload(true); } else if (me.new_href && me.target) { - const link_target = me.theme_id - ? `[data-target="${me.target}"][data-theme-id="${me.theme_id}"]` - : `[data-target="${me.target}"]`; - document.querySelectorAll(`link${link_target}`).forEach((link) => { - this.refreshCSS(link, me.new_href); - }); + const link_target = !!me.theme_id + ? `[data-target='${me.target}'][data-theme-id='${me.theme_id}']` + : `[data-target='${me.target}']`; + + const links = document.querySelectorAll(`link${link_target}`); + if (links.length > 0) { + const lastLink = links[links.length - 1]; + // this check is useful when message-bus has multiple file updates + // it avoids the browser doing a lot of work for nothing + // should the filenames be unchanged + if ( + lastLink.href.split("/").pop() !== me.new_href.split("/").pop() + ) { + this.refreshCSS(lastLink, me.new_href); + } + } } }); }, @@ -74,21 +84,14 @@ export default { }, refreshCSS(node, newHref) { - if (node.dataset.reloading) { - clearTimeout(node.dataset.timeout); - } - - node.dataset.reloading = true; - let reloaded = node.cloneNode(true); reloaded.href = newHref; node.insertAdjacentElement("afterend", reloaded); - let timeout = setTimeout(() => { - node.parentNode.removeChild(node); - reloaded.dataset.reloading = false; - }, 2000); - - node.dataset.timeout = timeout; + setTimeout(() => { + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } + }, 500); }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/message-bus.js b/app/assets/javascripts/discourse/app/initializers/message-bus.js index 2e2df03710..2309ba066e 100644 --- a/app/assets/javascripts/discourse/app/initializers/message-bus.js +++ b/app/assets/javascripts/discourse/app/initializers/message-bus.js @@ -86,7 +86,8 @@ export default { messageBus.baseUrl = siteSettings.long_polling_base_url.replace(/\/$/, "") + "/"; - messageBus.enableChunkedEncoding = siteSettings.enable_chunked_encoding; + messageBus.enableChunkedEncoding = + isProduction() && siteSettings.enable_chunked_encoding; if (messageBus.baseUrl !== "/") { messageBus.ajax = function (opts) { diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 994d45dfbd..5ffbaff63b 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -73,9 +73,10 @@ import { replaceFormatter } from "discourse/lib/utilities"; import { replaceTagRenderer } from "discourse/lib/render-tag"; import { setNewCategoryDefaultColors } from "discourse/routes/new-category"; import { addSearchResultsCallback } from "discourse/lib/search"; +import { addSearchSuggestion } from "discourse/widgets/search-menu-results"; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = "0.11.5"; +const PLUGIN_API_VERSION = "0.11.7"; class PluginApi { constructor(version, container) { @@ -1295,6 +1296,18 @@ class PluginApi { addSearchResultsCallback(callback) { addSearchResultsCallback(callback); } + + /** + * Add a suggestion shortcut to search menu panel. + * + * ``` + * addSearchSuggestion("in:assigned"); + * ``` + * + */ + addSearchSuggestion(value) { + addSearchSuggestion(value); + } } // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index b2de359e91..a53769b512 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -206,53 +206,30 @@ export function isValidSearchTerm(searchTerm, siteSettings) { } } -export function applySearchAutocomplete( - $input, - siteSettings, - appEvents, - options -) { - const afterComplete = function () { - if (appEvents) { - appEvents.trigger("search-autocomplete:after-complete"); - } - }; - +export function applySearchAutocomplete($input, siteSettings) { $input.autocomplete( - deepMerge( - { - template: findRawTemplate("category-tag-autocomplete"), - key: "#", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: false, - transformComplete(obj) { - return obj.text; - }, - dataSource(term) { - return searchCategoryTag(term, siteSettings); - }, - afterComplete, - }, - options - ) + deepMerge({ + template: findRawTemplate("category-tag-autocomplete"), + key: "#", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: false, + transformComplete: (obj) => obj.text, + dataSource: (term) => searchCategoryTag(term, siteSettings), + }) ); if (siteSettings.enable_mentions) { $input.autocomplete( - deepMerge( - { - template: findRawTemplate("user-selector-autocomplete"), - key: "@", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: false, - transformComplete: (v) => v.username || v.name, - dataSource: (term) => userSearch({ term, includeGroups: true }), - afterComplete, - }, - options - ) + deepMerge({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: false, + transformComplete: (v) => v.username || v.name, + dataSource: (term) => userSearch({ term, includeGroups: true }), + }) ); } } diff --git a/app/assets/javascripts/discourse/app/lib/user-search.js b/app/assets/javascripts/discourse/app/lib/user-search.js index 0f14ca40b4..79b3c5800b 100644 --- a/app/assets/javascripts/discourse/app/lib/user-search.js +++ b/app/assets/javascripts/discourse/app/lib/user-search.js @@ -22,6 +22,8 @@ function performSearch( allowedUsers, groupMembersOf, includeStagedUsers, + lastSeenUsers, + limit, resultsFn ) { let cached = cache[term]; @@ -32,7 +34,7 @@ function performSearch( const eagerComplete = eagerCompleteSearch(term, topicId || categoryId); - if (term === "" && !eagerComplete) { + if (term === "" && !eagerComplete && !lastSeenUsers) { // The server returns no results in this case, so no point checking // do not return empty list, because autocomplete will get terminated resultsFn(CANCELLED_STATUS); @@ -51,6 +53,8 @@ function performSearch( groups: groupMembersOf, topic_allowed_users: allowedUsers, include_staged_users: includeStagedUsers, + last_seen_users: lastSeenUsers, + limit: limit, }, }); @@ -93,6 +97,8 @@ let debouncedSearch = function ( allowedUsers, groupMembersOf, includeStagedUsers, + lastSeenUsers, + limit, resultsFn ) { discourseDebounce( @@ -107,6 +113,8 @@ let debouncedSearch = function ( allowedUsers, groupMembersOf, includeStagedUsers, + lastSeenUsers, + limit, resultsFn, 300 ); @@ -169,7 +177,10 @@ function organizeResults(r, options) { // we also ignore if we notice a double space or a string that is only a space const ignoreRegex = /([\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*,\/:;<=>?\[\]^`{|}~])|\s\s|^\s$|^[^+]*\+[^@]*$/; -export function skipSearch(term, allowEmails) { +export function skipSearch(term, allowEmails, lastSeenUsers = false) { + if (lastSeenUsers) { + return false; + } if (term.indexOf("@") > -1 && !allowEmails) { return true; } @@ -194,7 +205,9 @@ export default function userSearch(options) { topicId = options.topicId, categoryId = options.categoryId, groupMembersOf = options.groupMembersOf, - includeStagedUsers = options.includeStagedUsers; + includeStagedUsers = options.includeStagedUsers, + lastSeenUsers = options.lastSeenUsers, + limit = options.limit || 6; if (oldSearch) { oldSearch.abort(); @@ -217,7 +230,7 @@ export default function userSearch(options) { clearPromise = later(() => resolve(CANCELLED_STATUS), 5000); } - if (skipSearch(term, options.allowEmails)) { + if (skipSearch(term, options.allowEmails, options.lastSeenUsers)) { resolve([]); return; } @@ -232,6 +245,8 @@ export default function userSearch(options) { allowedUsers, groupMembersOf, includeStagedUsers, + lastSeenUsers, + limit, function (r) { cancel(clearPromise); resolve(organizeResults(r, options)); diff --git a/app/assets/javascripts/discourse/app/models/category.js b/app/assets/javascripts/discourse/app/models/category.js index af65076dba..940bb5ba39 100644 --- a/app/assets/javascripts/discourse/app/models/category.js +++ b/app/assets/javascripts/discourse/app/models/category.js @@ -177,6 +177,11 @@ const Category = RestModel.extend({ return topicCount; }, + @discourseComputed("default_slow_mode_seconds") + defaultSlowModeMinutes(seconds) { + return seconds ? seconds / 60 : null; + }, + save() { const id = this.id; const url = id ? `/categories/${id}` : "/categories"; @@ -193,6 +198,7 @@ const Category = RestModel.extend({ auto_close_based_on_last_post: this.get( "auto_close_based_on_last_post" ), + default_slow_mode_seconds: this.default_slow_mode_seconds, position: this.position, email_in: this.email_in, email_in_allow_strangers: this.email_in_allow_strangers, diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index 5fcbf4618b..75d91ea0c9 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -114,6 +114,12 @@ const Group = RestModel.extend({ }).then(() => this.findMembers(params, true)); }, + leave() { + return ajax(`/groups/${this.id}/leave.json`, { + type: "DELETE", + }).then(() => this.findMembers({}, true)); + }, + addMembers(usernames, filter, notifyUsers, emails = []) { return ajax(`/groups/${this.id}/members.json`, { type: "PUT", @@ -127,6 +133,14 @@ const Group = RestModel.extend({ }); }, + join() { + return ajax(`/groups/${this.id}/join.json`, { + type: "PUT", + }).then(() => { + this.findMembers({}, true); + }); + }, + addOwners(usernames, filter, notifyUsers) { return ajax(`/admin/groups/${this.id}/owners.json`, { type: "PUT", diff --git a/app/assets/javascripts/discourse/app/models/user-badge.js b/app/assets/javascripts/discourse/app/models/user-badge.js index 07467f3909..2bba997031 100644 --- a/app/assets/javascripts/discourse/app/models/user-badge.js +++ b/app/assets/javascripts/discourse/app/models/user-badge.js @@ -22,12 +22,14 @@ const UserBadge = EmberObject.extend({ }, favorite() { - return ajax(`/user_badges/${this.id}/toggle_favorite`, { type: "PUT" }) - .then((json) => { - this.set("is_favorite", json.user_badge.is_favorite); - return this; - }) - .catch(popupAjaxError); + this.toggleProperty("is_favorite"); + return ajax(`/user_badges/${this.id}/toggle_favorite`, { + type: "PUT", + }).catch((e) => { + // something went wrong, switch the UI back: + this.toggleProperty("is_favorite"); + popupAjaxError(e); + }); }, }); diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 4b2b4e905f..fc28a3b31f 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -37,7 +37,7 @@ export const SECOND_FACTOR_METHODS = { SECURITY_KEY: 3, }; -const isForever = (dt) => moment().diff(dt, "years") < -500; +const isForever = (dt) => moment().diff(dt, "years") < -100; let userFields = [ "bio_raw", diff --git a/app/assets/javascripts/discourse/app/routes/review-index.js b/app/assets/javascripts/discourse/app/routes/review-index.js index 187c91c953..a19b0f9952 100644 --- a/app/assets/javascripts/discourse/app/routes/review-index.js +++ b/app/assets/javascripts/discourse/app/routes/review-index.js @@ -39,6 +39,8 @@ export default DiscourseRoute.extend({ sort_order: meta.sort_order, additionalFilters: meta.additional_filters || {}, }); + + controller.reviewables.setEach("last_performing_username", null); }, activate() { @@ -62,7 +64,6 @@ export default DiscourseRoute.extend({ const updates = data.updates[reviewable.id]; if (updates) { reviewable.setProperties(updates); - reviewable.set("stale", true); } }); } diff --git a/app/assets/javascripts/discourse/app/routes/topic-by-slug-or-id.js b/app/assets/javascripts/discourse/app/routes/topic-by-slug-or-id.js index 0cfe24473f..f2805aedd1 100644 --- a/app/assets/javascripts/discourse/app/routes/topic-by-slug-or-id.js +++ b/app/assets/javascripts/discourse/app/routes/topic-by-slug-or-id.js @@ -12,6 +12,17 @@ export default DiscourseRoute.extend({ }, afterModel(result) { - DiscourseURL.routeTo(result.url, { replaceURL: true }); + // Using { replaceURL: true } to replace the current incomplete URL with + // the complete one is working incorrectly. + // + // Let's consider an example where the user is at /t/-/1. If they click on + // a link to /t/2 the expected behavior is to take the user to /t/2 that + // will redirect to /t/-/2 and generate a history with two entries: /t/-/1 + // followed by /t/-/2. + // + // When { replaceURL: true } is present, the history contains a single + // entry /t/-/2. This suggests that `afterModel` is called in the context + // of the referrer replacing its entry with the new one. + DiscourseURL.routeTo(result.url); }, }); diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js index f108061ad7..29f567404a 100644 --- a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js +++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js @@ -43,7 +43,7 @@ export default class MediaOptimizationWorkerService extends Service { if ( file.size < this.siteSettings - .composer_media_optimization_image_kilobytes_optimization_threshold + .composer_media_optimization_image_bytes_optimization_threshold ) { return data; } diff --git a/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs index ed237acd6d..a715b2268b 100644 --- a/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/basic-topic-list.hbs @@ -2,7 +2,7 @@ {{#if hasIncoming}}
- {{count-i18n key="topic_count_" suffix="latest" count=2}} + {{count-i18n key="topic_count_" suffix="latest" count=incomingCount}}
{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs index 78c3414a36..8dc72c5702 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs @@ -116,6 +116,21 @@ +
+
+ +
+ {{relative-time-picker + id="category-default-slow-mode" + durationMinutes=category.defaultSlowModeMinutes + hiddenIntervals=hiddenRelativeIntervals + onChange=(action "onDefaultSlowModeDurationChange")}} +
+
+
+
- {{#if reviewable.stale}} -
{{i18n "review.stale_help"}}
+ {{#if reviewable.last_performing_username}} +
{{html-safe (i18n "review.stale_help" username=reviewable.last_performing_username)}}
{{else}} {{#if claimEnabled}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/share-popup.hbs b/app/assets/javascripts/discourse/app/templates/components/share-popup.hbs index 695be0aeb5..5852650929 100644 --- a/app/assets/javascripts/discourse/app/templates/components/share-popup.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/share-popup.hbs @@ -4,13 +4,21 @@ {{#if date}} {{displayDate}} {{/if}} + + {{d-button + action=(action "close") + class="btn btn-flat close" + icon="times" + aria-label="share.close" + title="share.close" + }}
-
- + -
+