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