diff --git a/Gemfile.lock b/Gemfile.lock index 8097db1342..ecddef57d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,10 +129,10 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.88.0) + excon (0.89.0) execjs (2.8.1) exifr (1.3.9) - fabrication (2.22.0) + fabrication (2.23.0) faker (2.19.0) i18n (>= 1.6, < 2) fakeweb (1.3.0) @@ -215,8 +215,8 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.10.0) - loofah (2.12.0) + logster (2.10.1) + loofah (2.13.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) @@ -233,7 +233,7 @@ GEM mini_scheduler (0.13.0) sidekiq (>= 4.2.3) mini_sql (1.1.3) - mini_suffix (0.3.2) + mini_suffix (0.3.3) ffi (~> 1.9) minitest (5.14.4) mocha (1.13.0) @@ -292,7 +292,7 @@ GEM parallel (1.21.0) parallel_tests (3.7.3) parallel - parser (3.0.3.1) + parser (3.0.3.2) ast (~> 2.4.1) pg (1.2.3) progress (3.6.0) @@ -335,7 +335,7 @@ GEM rake (>= 0.13) thor (~> 1.0) rainbow (3.0.0) - raindrops (0.19.2) + raindrops (0.20.0) rake (13.0.6) rb-fsevent (0.11.0) rb-inotify (0.10.1) @@ -349,7 +349,7 @@ GEM redis (4.5.1) redis-namespace (1.8.1) redis (>= 3.0.4) - regexp_parser (2.1.1) + regexp_parser (2.2.0) request_store (1.5.0) rack (>= 1.4) rexml (3.2.5) @@ -399,9 +399,9 @@ GEM rubocop-ast (>= 1.12.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.13.0) + rubocop-ast (1.15.0) parser (>= 3.0.1.1) - rubocop-discourse (2.4.2) + rubocop-discourse (2.5.0) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) rubocop-rspec (2.6.0) @@ -443,7 +443,7 @@ GEM sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.4.1) + sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) diff --git a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js index 54551537da..be663cadb1 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js @@ -1,3 +1,4 @@ +import { action } from "@ember/object"; import Controller from "@ember/controller"; import discourseComputed from "discourse-common/utils/decorators"; @@ -13,13 +14,11 @@ export default Controller.extend({ .compact(); }, - actions: { - clearFilter() { - this.setProperties({ filter: "", onlyOverridden: false }); - }, - - toggleMenu() { - $(".admin-detail").toggleClass("mobile-closed mobile-open"); - }, + @action + toggleMenu() { + const adminDetail = document.querySelector(".admin-detail"); + ["mobile-closed", "mobile-open"].forEach((state) => { + adminDetail.classList.toggle(state); + }); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js b/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js index 58859ae933..dc5ba1193d 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js @@ -5,6 +5,7 @@ import { alias } from "@ember/object/computed"; import discourseDebounce from "discourse-common/lib/debounce"; import { isEmpty } from "@ember/utils"; import { observes } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; export default Controller.extend({ filter: null, @@ -126,13 +127,16 @@ export default Controller.extend({ ); }, - actions: { - clearFilter() { - this.setProperties({ filter: "", onlyOverridden: false }); - }, + @action + clearFilter() { + this.setProperties({ filter: "", onlyOverridden: false }); + }, - toggleMenu() { - $(".admin-detail").toggleClass("mobile-closed mobile-open"); - }, + @action + toggleMenu() { + const adminDetail = document.querySelector(".admin-detail"); + ["mobile-closed", "mobile-open"].forEach((state) => { + adminDetail.classList.toggle(state); + }); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js index 1830e4c742..e6966d558c 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js @@ -50,6 +50,9 @@ export default Controller.extend({ @action toggleMenu() { - $(".admin-detail").toggleClass("mobile-closed mobile-open"); + const adminDetail = document.querySelector(".admin-detail"); + ["mobile-closed", "mobile-open"].forEach((state) => { + adminDetail.classList.toggle(state); + }); }, }); diff --git a/app/assets/javascripts/admin/addon/templates/backups-index.hbs b/app/assets/javascripts/admin/addon/templates/backups-index.hbs index da2e488425..7c30f00ef0 100644 --- a/app/assets/javascripts/admin/addon/templates/backups-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/backups-index.hbs @@ -12,7 +12,7 @@ class="btn-default"}} {{/if}} {{else}} - {{#if (and siteSettings.enable_direct_s3_uploads siteSettings.enable_experimental_backup_uploader)}} + {{#if siteSettings.enable_experimental_backup_uploader}} {{uppy-backup-uploader done=(route-action "remoteUploadSuccess")}} {{else}} {{backup-uploader done=(route-action "remoteUploadSuccess")}} diff --git a/app/assets/javascripts/admin/addon/templates/plugins.hbs b/app/assets/javascripts/admin/addon/templates/plugins.hbs index 9dad110efc..054a6d1006 100644 --- a/app/assets/javascripts/admin/addon/templates/plugins.hbs +++ b/app/assets/javascripts/admin/addon/templates/plugins.hbs @@ -26,5 +26,3 @@
{{outlet}}
- -
diff --git a/app/assets/javascripts/discourse-common/package.json b/app/assets/javascripts/discourse-common/package.json index ce6bb534ee..9f39f2e1ad 100644 --- a/app/assets/javascripts/discourse-common/package.json +++ b/app/assets/javascripts/discourse-common/package.json @@ -19,7 +19,13 @@ "ember-cli-htmlbars": "^4.2.0", "ember-auto-import": "^1.5.3", "handlebars": "^4.7.0", - "truth-helpers": "^1.0.0" + "truth-helpers": "^1.0.0", + "@uppy/aws-s3": "^2.0.4", + "@uppy/aws-s3-multipart": "^2.1.0", + "@uppy/core": "^2.1.0", + "@uppy/drop-target": "^1.1.0", + "@uppy/utils": "^4.0.3", + "@uppy/xhr-upload": "^2.0.4" }, "devDependencies": { "@ember/optional-features": "^1.1.0", diff --git a/app/assets/javascripts/discourse/app/components/add-category-tag-classes.js b/app/assets/javascripts/discourse/app/components/add-category-tag-classes.js index 8e8dd86683..8cb6081fe8 100644 --- a/app/assets/javascripts/discourse/app/components/add-category-tag-classes.js +++ b/app/assets/javascripts/discourse/app/components/add-category-tag-classes.js @@ -15,21 +15,19 @@ export default Component.extend({ return; } const slug = this.get("category.fullSlug"); - const tags = this.tags; this._removeClass(); - let classes = []; + const classes = []; + if (slug) { classes.push("category"); classes.push(`category-${slug}`); } - if (tags) { - tags.forEach((t) => classes.push(`tag-${t}`)); - } - if (classes.length > 0) { - $("body").addClass(classes.join(" ")); - } + + this.tags?.forEach((t) => classes.push(`tag-${t}`)); + + document.body.classList.add(...classes); }, @observes("category.fullSlug", "tags") @@ -37,14 +35,23 @@ export default Component.extend({ scheduleOnce("afterRender", this, this._updateClass); }, - _removeClass() { - $("body").removeClass((_, css) => - (css.match(/\b(?:category|tag)-\S+|( category )/g) || []).join(" ") - ); - }, - willDestroyElement() { this._super(...arguments); this._removeClass(); }, + + _removeClass() { + const invalidClasses = []; + const regex = /\b(?:category|tag)-\S+|( category )/g; + + document.body.classList.forEach((name) => { + if (regex.test(name)) { + invalidClasses.push(name); + } + }); + + if (invalidClasses.length) { + document.body.classList.remove(...invalidClasses); + } + }, }); diff --git a/app/assets/javascripts/discourse/app/components/choose-topic.js b/app/assets/javascripts/discourse/app/components/choose-topic.js index 6454ae049a..a19c072628 100644 --- a/app/assets/javascripts/discourse/app/components/choose-topic.js +++ b/app/assets/javascripts/discourse/app/components/choose-topic.js @@ -1,8 +1,8 @@ import discourseComputed, { observes } from "discourse-common/utils/decorators"; import Component from "@ember/component"; +import { action } from "@ember/object"; import discourseDebounce from "discourse-common/lib/debounce"; import { isEmpty } from "@ember/utils"; -import { next, schedule } from "@ember/runloop"; import { searchForTerm } from "discourse/lib/search"; import { INPUT_DELAY } from "discourse-common/config/environment"; @@ -26,7 +26,7 @@ export default Component.extend({ if (this.loadOnInit && !isEmpty(this.additionalFilters)) { searchForTerm(this.additionalFilters, {}).then((results) => { - if (results && results.posts && results.posts.length > 0) { + if (results?.posts?.length > 0) { this.set( "topics", results.posts @@ -42,18 +42,18 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); - schedule("afterRender", () => { - $("#choose-topic-title").keydown((e) => { - if (e.key === "Enter") { - return false; - } - }); - }); + + document + .getElementById("choose-topic-title") + .addEventListener("keydown", this._handleEnter); }, willDestroyElement() { this._super(...arguments); - $("#choose-topic-title").off("keydown"); + + document + .getElementById("choose-topic-title") + .removeEventListener("keydown", this._handleEnter); }, @observes("topicTitle") @@ -102,7 +102,7 @@ export default Component.extend({ const currentTopicId = this.currentTopicId; const titleWithFilters = `${title} ${this.additionalFilters}`; - let searchParams = {}; + const searchParams = {}; if (!isEmpty(title)) { searchParams.typeFilter = "topic"; @@ -116,7 +116,7 @@ export default Component.extend({ if (title !== this.topicTitle) { return; } - if (results && results.posts && results.posts.length > 0) { + if (results?.posts?.length > 0) { this.set( "topics", results.posts.mapBy("topic").filter((t) => t.id !== currentTopicId) @@ -130,15 +130,18 @@ export default Component.extend({ }); }, - actions: { - chooseTopic(topic) { - this.set("selectedTopicId", topic.id); - next(() => { - document.getElementById(`choose-topic-${topic.id}`).checked = true; - }); - if (this.topicChangedCallback) { - this.topicChangedCallback(topic); - } - }, + @action + chooseTopic(topic) { + this.set("selectedTopicId", topic.id); + + if (this.topicChangedCallback) { + this.topicChangedCallback(topic); + } + }, + + _handleEnter(event) { + if (event.key === "Enter") { + event.preventDefault(); + } }, }); diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 1b0ce15734..16d3796e42 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -285,6 +285,9 @@ export default Component.extend(TextareaTextManipulation, { }); }); + this._itsatrap.bind("tab", () => this._indentSelection("right")); + this._itsatrap.bind("shift+tab", () => this._indentSelection("left")); + // disable clicking on links in the preview this.element .querySelector(".d-editor-preview") @@ -294,6 +297,11 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.on("composer:insert-block", this, "_insertBlock"); this.appEvents.on("composer:insert-text", this, "_insertText"); this.appEvents.on("composer:replace-text", this, "_replaceText"); + this.appEvents.on( + "composer:indent-selected-text", + this, + "_indentSelection" + ); } if (isTesting()) { @@ -333,6 +341,11 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.off("composer:insert-block", this, "_insertBlock"); this.appEvents.off("composer:insert-text", this, "_insertText"); this.appEvents.off("composer:replace-text", this, "_replaceText"); + this.appEvents.off( + "composer:indent-selected-text", + this, + "_indentSelection" + ); } this._itsatrap?.destroy(); diff --git a/app/assets/javascripts/discourse/app/components/d-section.js b/app/assets/javascripts/discourse/app/components/d-section.js index 5197680242..3b5433b3ea 100644 --- a/app/assets/javascripts/discourse/app/components/d-section.js +++ b/app/assets/javascripts/discourse/app/components/d-section.js @@ -1,24 +1,35 @@ +import deprecated from "discourse-common/lib/deprecated"; import Component from "@ember/component"; import { scrollTop } from "discourse/mixins/scroll-top"; // Can add a body class from within a component, also will scroll to the top automatically. export default Component.extend({ - tagName: "section", + tagName: null, + pageClass: null, + bodyClass: null, + scrollTop: true, didInsertElement() { this._super(...arguments); - const pageClass = this.pageClass; - if (pageClass) { - $("body").addClass(`${pageClass}-page`); + if (this.pageClass) { + document.body.classList.add(`${this.pageClass}-page`); } - const bodyClass = this.bodyClass; - if (bodyClass) { - $("body").addClass(bodyClass); + if (this.bodyClass) { + document.body.classList.add(...this.bodyClass.split(" ")); } if (this.scrollTop === "false") { + deprecated("Uses boolean instead of string for scrollTop.", { + since: "2.8.0.beta9", + dropFrom: "2.9.0.beta1", + }); + + return; + } + + if (!this.scrollTop) { return; } @@ -27,14 +38,13 @@ export default Component.extend({ willDestroyElement() { this._super(...arguments); - const pageClass = this.pageClass; - if (pageClass) { - $("body").removeClass(`${pageClass}-page`); + + if (this.pageClass) { + document.body.classList.remove(`${this.pageClass}-page`); } - const bodyClass = this.bodyClass; - if (bodyClass) { - $("body").removeClass(bodyClass); + if (this.bodyClass) { + document.body.classList.remove(...this.bodyClass.split(" ")); } }, }); diff --git a/app/assets/javascripts/discourse/app/components/discovery-categories.js b/app/assets/javascripts/discourse/app/components/discovery-categories.js index 4be34e7e4f..3843a4366e 100644 --- a/app/assets/javascripts/discourse/app/components/discovery-categories.js +++ b/app/assets/javascripts/discourse/app/components/discovery-categories.js @@ -1,19 +1,20 @@ import Component from "@ember/component"; import UrlRefresh from "discourse/mixins/url-refresh"; -import { on } from "discourse-common/utils/decorators"; const CATEGORIES_LIST_BODY_CLASS = "categories-list"; export default Component.extend(UrlRefresh, { classNames: ["contents"], - @on("didInsertElement") - addBodyClass() { - $("body").addClass(CATEGORIES_LIST_BODY_CLASS); + didInsertElement() { + this._super(...arguments); + + document.body.classList.add(CATEGORIES_LIST_BODY_CLASS); }, - @on("willDestroyElement") - removeBodyClass() { - $("body").removeClass(CATEGORIES_LIST_BODY_CLASS); + willDestroyElement() { + this._super(...arguments); + + document.body.classList.remove(CATEGORIES_LIST_BODY_CLASS); }, }); diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js index 3d04b226bb..9fab42b390 100644 --- a/app/assets/javascripts/discourse/app/components/emoji-picker.js +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js @@ -37,6 +37,7 @@ export default Component.extend({ hoveredEmoji: null, isActive: false, isLoading: true, + usePopper: true, init() { this._super(...arguments); @@ -88,25 +89,24 @@ export default Component.extend({ return; } - if (!this.site.isMobileDevice) { - this._popper = createPopper( - document.querySelector(".d-editor-textarea-wrapper"), - emojiPicker, - { - placement: "auto", - modifiers: [ - { - name: "preventOverflow", + const textareaWrapper = document.querySelector( + ".d-editor-textarea-wrapper" + ); + if (!this.site.isMobileDevice && this.usePopper && textareaWrapper) { + this._popper = createPopper(textareaWrapper, emojiPicker, { + placement: "auto", + modifiers: [ + { + name: "preventOverflow", + }, + { + name: "offset", + options: { + offset: [5, 5], }, - { - name: "offset", - options: { - offset: [5, 5], - }, - }, - ], - } - ); + }, + ], + }); } // this is a low-tech trick to prevent appending hundreds of emojis diff --git a/app/assets/javascripts/discourse/app/components/groups-form-membership-fields.js b/app/assets/javascripts/discourse/app/components/groups-form-membership-fields.js index 7603df532c..5292efa585 100644 --- a/app/assets/javascripts/discourse/app/components/groups-form-membership-fields.js +++ b/app/assets/javascripts/discourse/app/components/groups-form-membership-fields.js @@ -1,11 +1,13 @@ import Component from "@ember/component"; import I18n from "I18n"; import { computed } from "@ember/object"; -import { not } from "@ember/object/computed"; +import { not, readOnly } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; +import AssociatedGroup from "discourse/models/associated-group"; export default Component.extend({ tokenSeparator: "|", + showAssociatedGroups: readOnly("site.can_associate_groups"), init() { this._super(...arguments); @@ -20,6 +22,10 @@ export default Component.extend({ { name: 3, value: 3 }, { name: 4, value: 4 }, ]; + + if (this.showAssociatedGroups) { + this.loadAssociatedGroups(); + } }, canEdit: not("model.automatic"), @@ -54,6 +60,10 @@ export default Component.extend({ return this.model.emailDomains.split(this.tokenSeparator).filter(Boolean); }), + loadAssociatedGroups() { + AssociatedGroup.list().then((ags) => this.set("associatedGroups", ags)); + }, + actions: { onChangeEmailDomainsSetting(value) { this.set( diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index 32045dec66..2be7047b78 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -183,9 +183,13 @@ const SiteHeaderComponent = MountWidget.extend( } const offset = info.offset(); - const headerRect = header.getBoundingClientRect(), - headerOffset = headerRect.top + headerRect.height, - doc = document.documentElement; + const headerRect = header.getBoundingClientRect(); + const doc = document.documentElement; + let headerOffset = headerRect.top + headerRect.height; + + if (window.scrollY < 0) { + headerOffset -= window.scrollY; + } const newValue = `${headerOffset}px`; if (newValue !== this.currentHeaderOffsetValue) { diff --git a/app/assets/javascripts/discourse/app/components/tag-info.js b/app/assets/javascripts/discourse/app/components/tag-info.js index 60ddb3ba60..e1402a2327 100644 --- a/app/assets/javascripts/discourse/app/components/tag-info.js +++ b/app/assets/javascripts/discourse/app/components/tag-info.js @@ -93,13 +93,17 @@ export default Component.extend({ }, finishedEditing() { + const oldTagName = this.tag.id; this.tag .update({ id: this.newTagName, description: this.newTagDescription }) .then((result) => { this.set("editing", false); this.tagInfo.set("description", this.newTagDescription); - if (result.payload) { - this.router.transitionTo("tag.show", result.payload.id); + if ( + result.responseJson.tag && + oldTagName !== result.responseJson.tag.id + ) { + this.router.transitionTo("tag.show", result.responseJson.tag.id); } }) .catch(popupAjaxError); diff --git a/app/assets/javascripts/discourse/app/components/topic-status.js b/app/assets/javascripts/discourse/app/components/topic-status.js index 0cea69125b..3461ed20d2 100644 --- a/app/assets/javascripts/discourse/app/components/topic-status.js +++ b/app/assets/javascripts/discourse/app/components/topic-status.js @@ -4,6 +4,8 @@ import discourseComputed from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; export default Component.extend({ + disableActions: false, + classNames: ["topic-statuses"], click(e) { diff --git a/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js b/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js index ac07d28a40..9311b35b75 100644 --- a/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js +++ b/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js @@ -1,5 +1,5 @@ import Component from "@ember/component"; -import { alias, not } from "@ember/object/computed"; +import { alias } from "@ember/object/computed"; import I18n from "I18n"; import UppyUploadMixin from "discourse/mixins/uppy-upload"; import discourseComputed from "discourse-common/utils/decorators"; @@ -12,15 +12,11 @@ export default Component.extend(UppyUploadMixin, { uploadRootPath: "/admin/backups", uploadUrl: "/admin/backups/upload", - // TODO (martin) Add functionality to make this usable _without_ multipart - // uploads, direct to S3, which needs to call get-presigned-put on the - // BackupsController (which extends ExternalUploadHelpers) rather than - // the old create_upload_url route. The two are functionally equivalent; - // they both generate a presigned PUT url for the upload to S3, and do - // the whole thing in one request rather than multipart. - // direct s3 backups - useMultipartUploadsIfAvailable: not("localBackupStorage"), + @discourseComputed("localBackupStorage") + useMultipartUploadsIfAvailable(localBackupStorage) { + return !localBackupStorage && this.siteSettings.enable_direct_s3_uploads; + }, // local backups useChunkedUploads: alias("localBackupStorage"), diff --git a/app/assets/javascripts/discourse/app/components/user-card-contents.js b/app/assets/javascripts/discourse/app/components/user-card-contents.js index 3c64c2fc83..db6621cc71 100644 --- a/app/assets/javascripts/discourse/app/components/user-card-contents.js +++ b/app/assets/javascripts/discourse/app/components/user-card-contents.js @@ -174,7 +174,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { include_post_count_for: this.get("topic.id"), }; - User.findByUsername(username, args) + return User.findByUsername(username, args) .then((user) => { if (user.topic_post_count) { this.set( @@ -183,6 +183,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { ); } this.setProperties({ user }); + return user; }) .catch(() => this._close()) .finally(() => this.set("loading", null)); diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 2a4cb7e841..8494aad52f 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -1,9 +1,10 @@ import Controller from "@ember/controller"; import { action } from "@ember/object"; -import { empty, notEmpty } from "@ember/object/computed"; +import { not } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import { extractError } from "discourse/lib/ajax-error"; import { getNativeContact } from "discourse/lib/pwa-utils"; +import { emailValid, hostnameValid } from "discourse/lib/utilities"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import Group from "discourse/models/group"; @@ -28,8 +29,17 @@ export default Controller.extend( inviteToTopic: false, limitToEmail: false, - isLink: empty("buffered.email"), - isEmail: notEmpty("buffered.email"), + @discourseComputed("buffered.emailOrDomain") + isEmail(emailOrDomain) { + return emailValid(emailOrDomain); + }, + + @discourseComputed("buffered.emailOrDomain") + isDomain(emailOrDomain) { + return hostnameValid(emailOrDomain); + }, + + isLink: not("isEmail"), onShow() { Group.findAll().then((groups) => { @@ -67,6 +77,15 @@ export default Controller.extend( save(opts) { const data = { ...this.buffered.buffer }; + if (data.emailOrDomain) { + if (emailValid(data.emailOrDomain)) { + data.email = data.emailOrDomain; + } else if (hostnameValid(data.emailOrDomain)) { + data.domain = data.emailOrDomain; + } + delete data.emailOrDomain; + } + if (data.groupIds !== undefined) { data.group_ids = data.groupIds.length > 0 ? data.groupIds : ""; delete data.groupIds; diff --git a/app/assets/javascripts/discourse/app/controllers/flag.js b/app/assets/javascripts/discourse/app/controllers/flag.js index 042bea17de..49ea8b525d 100644 --- a/app/assets/javascripts/discourse/app/controllers/flag.js +++ b/app/assets/javascripts/discourse/app/controllers/flag.js @@ -275,6 +275,10 @@ export default Controller.extend(ModalFunctionality, { postAction .act(this.model, params) .then(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + if (!params.skipClose) { this.send("closeModal"); } @@ -286,7 +290,9 @@ export default Controller.extend(ModalFunctionality, { }); }) .catch((error) => { - this.send("closeModal"); + if (!this.isDestroying && !this.isDestroyed) { + this.send("closeModal"); + } popupAjaxError(error); }); }, diff --git a/app/assets/javascripts/discourse/app/controllers/full-page-search.js b/app/assets/javascripts/discourse/app/controllers/full-page-search.js index d75bc34c9b..bec3624eb4 100644 --- a/app/assets/javascripts/discourse/app/controllers/full-page-search.js +++ b/app/assets/javascripts/discourse/app/controllers/full-page-search.js @@ -13,7 +13,7 @@ import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; import { escapeExpression } from "discourse/lib/utilities"; import { isEmpty } from "@ember/utils"; -import { or } from "@ember/object/computed"; +import { gt, or } from "@ember/object/computed"; import { scrollTop } from "discourse/mixins/scroll-top"; import { setTransient } from "discourse/lib/page-tracker"; import { Promise } from "rsvp"; @@ -248,6 +248,13 @@ export default Controller.extend({ return this.currentUser && this.currentUser.staff && hasResults; }, + hasSelection: gt("selected.length", 0), + + @discourseComputed("selected.length", "model.posts.length") + hasUnselectedResults(selectionCount, postsCount) { + return selectionCount < postsCount; + }, + @discourseComputed("model.grouped_search_result.can_create_topic") canCreateTopic(userCanCreateTopic) { return this.currentUser && userCanCreateTopic; @@ -399,18 +406,28 @@ export default Controller.extend({ }, selectAll() { - this.selected.addObjects(this.get("model.posts").map((r) => r.topic)); + this.selected.addObjects(this.get("model.posts")).mapBy("topic"); + // Doing this the proper way is a HUGE pain, // we can hack this to work by observing each on the array // in the component, however, when we select ANYTHING, we would force // 50 traversals of the list // This hack is cheap and easy - $(".fps-result input[type=checkbox]").prop("checked", true); + document + .querySelectorAll(".fps-result input[type=checkbox]") + .forEach((checkbox) => { + checkbox.checked = true; + }); }, clearAll() { this.selected.clear(); - $(".fps-result input[type=checkbox]").prop("checked", false); + + document + .querySelectorAll(".fps-result input[type=checkbox]") + .forEach((checkbox) => { + checkbox.checked = false; + }); }, toggleBulkSelect() { diff --git a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js index 50c36460c1..866ddebd95 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -45,6 +45,13 @@ export default Controller.extend({ inviteExpired: equal("filter", "expired"), invitePending: equal("filter", "pending"), + @discourseComputed("model") + hasEmailInvites(model) { + return model.invites.some((invite) => { + return invite.email; + }); + }, + @discourseComputed("filter") showBulkActionButtons(filter) { return ( @@ -57,9 +64,9 @@ export default Controller.extend({ canInviteToForum: reads("currentUser.can_invite_to_forum"), canBulkInvite: reads("currentUser.admin"), - @discourseComputed("invitesCount.total") - showSearch(invitesCountTotal) { - return invitesCountTotal > 0; + @discourseComputed("invitesCount", "filter") + showSearch(invitesCount, filter) { + return invitesCount[filter] > 5; }, @action diff --git a/app/assets/javascripts/discourse/app/initializers/badging.js b/app/assets/javascripts/discourse/app/initializers/badging.js index 73c90b0aa1..a347e30f2a 100644 --- a/app/assets/javascripts/discourse/app/initializers/badging.js +++ b/app/assets/javascripts/discourse/app/initializers/badging.js @@ -13,15 +13,12 @@ export default { return; } // must be logged in - this.notifications = - user.unread_notifications + user.unread_high_priority_notifications; + const appEvents = container.lookup("service:app-events"); + appEvents.on("notifications:changed", () => { + const notifications = + user.unread_notifications + user.unread_high_priority_notifications; - container - .lookup("service:app-events") - .on("notifications:changed", this, "_updateBadge"); - }, - - _updateBadge() { - navigator.setAppBadge(this.notifications); + navigator.setAppBadge(notifications); + }); }, }; diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index f3bd4367c3..bf572e0eaa 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -682,6 +682,10 @@ export default { if ($article.length > 0) { $articles.removeClass("selected"); $article.addClass("selected"); + this.appEvents.trigger("keyboard:move-selection", { + articles: $articles.get(), + selectedArticle: $article.get(0), + }); const articleRect = $article[0].getBoundingClientRect(); if (!fast && direction < 0 && articleRect.height > window.innerHeight) { diff --git a/app/assets/javascripts/discourse/app/lib/uploads.js b/app/assets/javascripts/discourse/app/lib/uploads.js index ba3a214b9f..e5c7239659 100644 --- a/app/assets/javascripts/discourse/app/lib/uploads.js +++ b/app/assets/javascripts/discourse/app/lib/uploads.js @@ -120,7 +120,7 @@ export function validateUploadedFile(file, opts) { return true; } -const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|svg|ico|heic|heif|webp)/i; +export const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|svg|ico|heic|heif|webp)/i; function extensionsToArray(exts) { return exts diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index c6a5ca1446..dff0909873 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -134,6 +134,7 @@ export function highlightPost(postNumber) { element.removeEventListener("animationend", removeHighlighted); }; element.addEventListener("animationend", removeHighlighted); + container.querySelector(".tabLoc").focus(); } export function emailValid(email) { @@ -142,6 +143,12 @@ export function emailValid(email) { return re.test(email); } +export function hostnameValid(hostname) { + // see: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address + const re = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; + return hostname && re.test(hostname); +} + export function extractDomainFromUrl(url) { if (url.indexOf("://") > -1) { url = url.split("/")[2]; diff --git a/app/assets/javascripts/discourse/app/mixins/card-contents-base.js b/app/assets/javascripts/discourse/app/mixins/card-contents-base.js index d5843e9afb..b56179c734 100644 --- a/app/assets/javascripts/discourse/app/mixins/card-contents-base.js +++ b/app/assets/javascripts/discourse/app/mixins/card-contents-base.js @@ -86,7 +86,9 @@ export default Mixin.create({ }); this.appEvents.trigger("user-card:show", { username }); - this._showCallback(username, $(target)); + this._showCallback(username, $(target)).then((user) => { + this.appEvents.trigger("user-card:after-show", { user }); + }); // We bind scrolling on mobile after cards are shown to hide them if user scrolls if (this.site.mobileView) { diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js index 8db042cc1d..badd1590ab 100644 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js +++ b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js @@ -205,8 +205,6 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { this._useXHRUploads(); } - // TODO (martin) develop upload handler guidance and an API to use; will - // likely be using uppy plugins for this this._uppyInstance.on("file-added", (file) => { if (isPrivateMessage) { file.meta.for_private_message = true; @@ -273,6 +271,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { fileName: file.name, id: file.id, progress: 0, + extension: file.extension, }) ); const placeholder = this._uploadPlaceholder(file); diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js index 6dd67f74ad..ffdad12a25 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -11,10 +11,8 @@ import { } from "discourse/lib/utilities"; import { next, schedule } from "@ember/runloop"; -const isInside = (text, regex) => { - const matches = text.match(regex); - return matches && matches.length % 2; -}; +const INDENT_DIRECTION_LEFT = "left"; +const INDENT_DIRECTION_RIGHT = "right"; export default Mixin.create({ init() { @@ -134,7 +132,10 @@ export default Mixin.create({ this.set("value", val.replace(oldVal, newVal)); } - if (opts.forceFocus || this._$textarea.is(":focus")) { + if ( + (opts.forceFocus || this._$textarea.is(":focus")) && + !opts.skipNewSelection + ) { // Restore cursor. this._selectText( newSelection.start, @@ -234,6 +235,11 @@ export default Mixin.create({ return null; }, + _isInside(text, regex) { + const matches = text.match(regex); + return matches && matches.length % 2; + }, + @bind paste(e) { if (!this._$textarea.is(":focus") && !isTesting()) { @@ -253,7 +259,7 @@ export default Mixin.create({ const selected = this._getSelected(null, { lineVal: true }); const { pre, value: selectedValue, lineVal } = selected; const isInlinePasting = pre.match(/[^\n]$/); - const isCodeBlock = isInside(pre, /(^|\n)```/g); + const isCodeBlock = this._isInside(pre, /(^|\n)```/g); if ( plainText && @@ -273,7 +279,7 @@ export default Mixin.create({ if (isInlinePasting) { canPasteHtml = !( lineVal.match(/^```/) || - isInside(pre, /`/g) || + this._isInside(pre, /`/g) || lineVal.match(/^ /) ); } else { @@ -285,7 +291,11 @@ export default Mixin.create({ this._cachedLinkify && plainText && !handled && - selected.end > selected.start + selected.end > selected.start && + // text selection does not contain url + !this._cachedLinkify.test(selectedValue) && + // text selection does not contain a bbcode-like tag + !selectedValue.match(/\[\/?[a-z =]+?\]/g) ) { if (this._cachedLinkify.test(plainText)) { const match = this._cachedLinkify.match(plainText)[0]; @@ -311,8 +321,10 @@ export default Mixin.create({ markdown = pre.match(/\S$/) ? ` ${markdown}` : markdown; } - this.appEvents.trigger("composer:insert-text", markdown); - handled = true; + if (isComposer) { + this.appEvents.trigger("composer:insert-text", markdown); + handled = true; + } } } @@ -321,6 +333,94 @@ export default Mixin.create({ } }, + /** + * Removes the provided char from the provided str up + * until the limit, or until a character that is _not_ + * the provided one is encountered. + */ + _deindentLine(str, char, limit) { + let eaten = 0; + for (let i = 0; i < str.length; i++) { + if (eaten < limit && str[i] === char) { + eaten += 1; + } else { + return str.slice(eaten); + } + } + return str; + }, + + @bind + _indentSelection(direction) { + if (![INDENT_DIRECTION_LEFT, INDENT_DIRECTION_RIGHT].includes(direction)) { + return; + } + + const selected = this._getSelected(null, { lineVal: true }); + const { lineVal } = selected; + let value = selected.value; + + // Perhaps this is a bit simplistic, but it is a fairly reliable + // guess to say whether we are indenting with tabs or spaces. for + // example some programming languages prefer tabs, others prefer + // spaces, and for the cases with no tabs it's safer to use spaces + let indentationSteps, indentationChar; + let linesStartingWithTabCount = value.match(/^\t/gm)?.length || 0; + let linesStartingWithSpaceCount = value.match(/^ /gm)?.length || 0; + if (linesStartingWithTabCount > linesStartingWithSpaceCount) { + indentationSteps = 1; + indentationChar = "\t"; + } else { + indentationChar = " "; + indentationSteps = 2; + } + + // We want to include all the spaces on the selected line as + // well, no matter where the cursor begins on the first line, + // because we want to indent those too. * is the cursor/selection + // and . are spaces: + // + // BEFORE AFTER + // + // * * + // ....text here ....text here + // ....some more text ....some more text + // * * + // + // BEFORE AFTER + // + // * * + // ....text here ....text here + // ....some more text ....some more text + // * * + const indentationRegexp = new RegExp(`^${indentationChar}+`); + const lineStartsWithIndentationChar = lineVal.match(indentationRegexp); + const intentationCharsBeforeSelection = value.match(indentationRegexp); + if (lineStartsWithIndentationChar) { + const charsToSubtract = intentationCharsBeforeSelection + ? intentationCharsBeforeSelection[0] + : ""; + value = + lineStartsWithIndentationChar[0].replace(charsToSubtract, "") + value; + } + + const splitSelection = value.split("\n"); + const newValue = splitSelection + .map((line) => { + if (direction === INDENT_DIRECTION_LEFT) { + return this._deindentLine(line, indentationChar, indentationSteps); + } else { + return `${Array(indentationSteps + 1).join(indentationChar)}${line}`; + } + }) + .join("\n"); + + if (newValue.trim() !== "") { + this._replaceText(value, newValue, { skipNewSelection: true }); + this._selectText(this.value.indexOf(newValue), newValue.length); + } + }, + @action emojiSelected(code) { let selected = this._getSelected(); diff --git a/app/assets/javascripts/discourse/app/models/associated-group.js b/app/assets/javascripts/discourse/app/models/associated-group.js new file mode 100644 index 0000000000..e914aaf824 --- /dev/null +++ b/app/assets/javascripts/discourse/app/models/associated-group.js @@ -0,0 +1,17 @@ +import EmberObject from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +const AssociatedGroup = EmberObject.extend(); + +AssociatedGroup.reopenClass({ + list() { + return ajax("/associated_groups") + .then((result) => { + return result.associated_groups.map((ag) => AssociatedGroup.create(ag)); + }) + .catch(popupAjaxError); + }, +}); + +export default AssociatedGroup; diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index 2f597b4dba..a360f8c3d8 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -29,6 +29,11 @@ const Group = RestModel.extend({ return isEmpty(value) ? "" : value; }, + @discourseComputed("associated_group_ids") + associatedGroupIds(value) { + return isEmpty(value) ? [] : value; + }, + @discourseComputed("automatic") type(automatic) { return automatic ? "automatic" : "custom"; @@ -277,6 +282,11 @@ const Group = RestModel.extend({ } ); + let agIds = this.associated_group_ids; + if (agIds) { + attrs["associated_group_ids"] = agIds.length ? agIds : [null]; + } + if (this.flair_type === "icon") { attrs["flair_icon"] = this.flair_icon; } else if (this.flair_type === "image") { diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js index 7ad702f223..122208bc1b 100644 --- a/app/assets/javascripts/discourse/app/models/invite.js +++ b/app/assets/javascripts/discourse/app/models/invite.js @@ -49,6 +49,11 @@ const Invite = EmberObject.extend({ return topicData ? Topic.create(topicData) : null; }, + @discourseComputed("email", "domain") + emailOrDomain(email, domain) { + return email || domain; + }, + topicId: alias("topics.firstObject.id"), topicTitle: alias("topics.firstObject.title"), }); diff --git a/app/assets/javascripts/discourse/app/models/post-stream.js b/app/assets/javascripts/discourse/app/models/post-stream.js index 9eeffd6c08..d2d90649d4 100644 --- a/app/assets/javascripts/discourse/app/models/post-stream.js +++ b/app/assets/javascripts/discourse/app/models/post-stream.js @@ -266,11 +266,13 @@ export default RestModel.extend({ filterReplies(postNumber, postId) { this.cancelFilter(); this.set("filterRepliesToPostNumber", postNumber); + this.appEvents.trigger("post-stream:filter-replies", { topic_id: this.get("topic.id"), post_number: postNumber, post_id: postId, }); + return this.refresh({ refreshInPlace: true }).then(() => { const element = document.querySelector(`#post_${postNumber}`); @@ -280,16 +282,13 @@ export default RestModel.extend({ : null; this.appEvents.trigger("post-stream:refresh"); + DiscourseURL.jumpToPost(postNumber, { originalTopOffset, }); - const replyPostNumbers = this.posts.mapBy("post_number"); - replyPostNumbers.splice(0, 2); schedule("afterRender", () => { - replyPostNumbers.forEach((postNum) => { - highlightPost(postNum); - }); + highlightPost(postNumber); }); }); }, diff --git a/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js b/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js index acc9e14fa5..b08994ccc0 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js @@ -41,6 +41,10 @@ export default { caps.hasContactPicker = "contacts" in navigator && "ContactsManager" in window; caps.canVibrate = "vibrate" in navigator; + caps.isPwa = + window.matchMedia("(display-mode: standalone)").matches || + window.navigator.standalone || + document.referrer.includes("android-app://"); // Inject it app.register("capabilities:main", caps, { instantiate: false }); diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index e0b8dae8e4..07569c08ce 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -19,6 +19,7 @@ import userPresent, { onPresenceChange, removeOnPresenceChange, } from "discourse/lib/user-presence"; +import { bind } from "discourse-common/utils/decorators"; const PRESENCE_INTERVAL_S = 30; const PRESENCE_DEBOUNCE_MS = isTesting() ? 0 : 500; @@ -26,7 +27,7 @@ const PRESENCE_THROTTLE_MS = isTesting() ? 0 : 1000; const PRESENCE_GET_RETRY_MS = 5000; -const USER_PRESENCE_ARGS = { +const DEFAULT_ACTIVE_OPTIONS = { userUnseenTime: 60000, browserHiddenTime: 10000, }; @@ -63,8 +64,19 @@ class PresenceChannel extends EmberObject { // By default, the user will temporarily 'leave' the channel when // the current tab is in the background, or has no interaction for more than 60 seconds. // To override this behaviour, set onlyWhileActive: false - async enter({ onlyWhileActive = true } = {}) { - this.setProperties({ onlyWhileActive }); + // To specify custom thresholds, set `activeOptions`. See `lib/user-presence.js` for options. + async enter({ onlyWhileActive = true, activeOptions = null } = {}) { + if (onlyWhileActive && activeOptions) { + for (const key in DEFAULT_ACTIVE_OPTIONS) { + if (activeOptions[key] < DEFAULT_ACTIVE_OPTIONS[key]) { + throw `${key} cannot be less than ${DEFAULT_ACTIVE_OPTIONS[key]} (given ${activeOptions[key]})`; + } + } + } else if (onlyWhileActive && !activeOptions) { + activeOptions = DEFAULT_ACTIVE_OPTIONS; + } + + this.setProperties({ activeOptions }); await this.presenceService._enter(this); this.set("present", true); } @@ -241,13 +253,10 @@ export default class PresenceService extends Service { this._initialDataRequests = new Map(); if (this.currentUser) { - this._beforeUnloadCallback = () => this._beaconLeaveAll(); - window.addEventListener("beforeunload", this._beforeUnloadCallback); - - this._presenceChangeCallback = () => this._throttledUpdateServer(); + window.addEventListener("beforeunload", this._beaconLeaveAll); onPresenceChange({ - ...USER_PRESENCE_ARGS, - callback: this._presenceChangeCallback, + ...DEFAULT_ACTIVE_OPTIONS, + callback: this._throttledUpdateServer, }); } } @@ -258,8 +267,8 @@ export default class PresenceService extends Service { willDestroy() { super.willDestroy(...arguments); - window.removeEventListener("beforeunload", this._beforeUnloadCallback); - removeOnPresenceChange(this._presenceChangeCallback); + window.removeEventListener("beforeunload", this._beaconLeaveAll); + removeOnPresenceChange(this._throttledUpdateServer); } // Get a PresenceChannel object representing a single channel @@ -440,6 +449,7 @@ export default class PresenceService extends Service { } } + @bind _beaconLeaveAll() { if (isTesting()) { return; @@ -490,15 +500,15 @@ export default class PresenceService extends Service { .filter((e) => e.type === "leave") .map((e) => e.channel); - const userIsPresent = userPresent(USER_PRESENCE_ARGS); for (const [channelName, proxies] of this._presentProxies) { if ( - !userIsPresent && - Array.from(proxies).every((p) => p.onlyWhileActive) + Array.from(proxies).some((p) => { + return !p.activeOptions || userPresent(p.activeOptions); + }) ) { - channelsToLeave.push(channelName); - } else { presentChannels.push(channelName); + } else { + channelsToLeave.push(channelName); } } @@ -551,6 +561,7 @@ export default class PresenceService extends Service { // in a sequence of calls. We want both. We want the first event, to make // things very responsive. Then if things are happening too frequently, we // drop back to the last event via the regular throttle function. + @bind _throttledUpdateServer() { if ( !this._lastUpdate || diff --git a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs b/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs index 7d384bccb2..88eec7cd6a 100644 --- a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs @@ -1,8 +1,15 @@ -{{text-field value=topicTitle placeholderKey="choose_topic.title.placeholder" id="choose-topic-title"}} +{{text-field + value=topicTitle + placeholderKey="choose_topic.title.placeholder" + id="choose-topic-title" +}} {{#if loading}}

{{i18n "loading"}}

@@ -13,13 +20,23 @@ {{#each topics as |t|}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs b/app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs index 2924226408..220f9dd497 100644 --- a/app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/composer-action-title.hbs @@ -10,6 +10,9 @@ post=model.post whisper=model.whisper noBump=model.noBump + options=(hash + mobilePlacementStrategy="fixed" + ) }} diff --git a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs b/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs index acec052510..bd309747db 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs @@ -31,7 +31,7 @@ {{#if tag}} {{#if showToggleInfo}} - {{d-button icon="wrench" class="btn-default" ariaLabel="tagging.info" action=toggleInfo id="show-tag-info"}} + {{d-button icon=(if currentUser.staff "wrench" "info-circle") class="btn-default" ariaLabel="tagging.info" action=toggleInfo id="show-tag-info"}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/components/groups-form-membership-fields.hbs b/app/assets/javascripts/discourse/app/templates/components/groups-form-membership-fields.hbs index bce462add0..98e85f36a9 100644 --- a/app/assets/javascripts/discourse/app/templates/components/groups-form-membership-fields.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/groups-form-membership-fields.hbs @@ -59,6 +59,23 @@ onChange=(action "onChangeEmailDomainsSetting") options=(hash allowAny=true) }} + + {{#if showAssociatedGroups}} + + + {{list-setting + name="automatic_membership_associated_groups" + class="group-form-automatic-membership-associated-groups" + value=model.associatedGroupIds + choices=associatedGroups + settingName="name" + nameProperty="label" + valueProperty="id" + onChange=(action (mut model.associated_group_ids)) + }} + {{/if}} {{plugin-outlet name="groups-form-membership-below-automatic" diff --git a/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs b/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs index 92aa9d2e9c..f598d6d939 100644 --- a/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs @@ -17,24 +17,11 @@
{{discourse-tag tagInfo.name tagName="div" size="large"}} {{#if canAdminTag}} - {{d-icon "pencil-alt"}} + {{d-button action=(action "edit") class="btn-flat edit-tag" title="tagging.edit_tag" icon="pencil-alt" }} {{/if}}
- {{#if canAdminTag}} -
- {{tagInfo.description}} -
- {{/if}} - {{/if}} - - {{#if canAdminTag}} - {{plugin-outlet name="tag-custom-settings" args=(hash tag=tagInfo) connectorTagName="" tagName="section"}} - -
- {{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}} - {{#if deleteAction}} - {{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}} - {{/if}} +
+ {{tagInfo.description}}
{{/if}}
@@ -53,7 +40,10 @@ {{#if tagInfo.category_restricted}} {{i18n "tagging.category_restricted"}} {{else}} - {{html-safe (i18n "tagging.default_info" basePath=(base-path))}} + {{html-safe (i18n "tagging.default_info")}} + {{#if canAdminTag}} + {{html-safe (i18n "tagging.staff_info" basePath=(base-path))}} + {{/if}} {{/if}} {{/if}} @@ -81,20 +71,31 @@ {{#if editSynonymsMode}}
- {{tag-chooser - id="add-synonyms" - tags=newSynonyms - blockedTags=(array tagInfo.name) - everyTag=true - excludeSynonyms=true - excludeHasSynonyms=true - unlimitedTagCount=true}} +
+ {{tag-chooser + id="add-synonyms" + tags=newSynonyms + blockedTags=(array tagInfo.name) + everyTag=true + excludeSynonyms=true + excludeHasSynonyms=true + unlimitedTagCount=true}} + {{d-button + class="ok" + action=(action "addSynonyms") + disabled=addSynonymsDisabled + icon="check"}} +
- {{d-button - class="btn-default" - action=(action "addSynonyms") - disabled=addSynonymsDisabled - label="tagging.add_synonyms"}} + {{/if}} + {{#if canAdminTag}} + {{plugin-outlet name="tag-custom-settings" args=(hash tag=tagInfo) connectorTagName="" tagName="section"}} +
+ {{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}} + {{#if deleteAction}} + {{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}} + {{/if}} +
{{/if}} {{/if}} {{#if loading}} diff --git a/app/assets/javascripts/discourse/app/templates/components/user-fields/confirm.hbs b/app/assets/javascripts/discourse/app/templates/components/user-fields/confirm.hbs index a60986f98b..d0e67af3ca 100644 --- a/app/assets/javascripts/discourse/app/templates/components/user-fields/confirm.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/user-fields/confirm.hbs @@ -1,5 +1,5 @@ {{#if this.field.name}} -