From aac3547cc2874f80f67f99e75a59ecf1a124c49e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 20 Oct 2021 23:04:08 +0100 Subject: [PATCH 001/254] DEV: Update AWS API stub following gem version bump (#14673) The latest version of the gem doesn't send whitespace in this request body, so we need to update the test stub accordingly --- spec/requests/uploads_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/uploads_controller_spec.rb b/spec/requests/uploads_controller_spec.rb index b142eed0f6..def58bec7e 100644 --- a/spec/requests/uploads_controller_spec.rb +++ b/spec/requests/uploads_controller_spec.rb @@ -1201,7 +1201,7 @@ describe UploadsController do :post, "#{temp_location}?uploadId=#{external_upload_stub.external_upload_identifier}" ).with( - body: "\n \n test1\n 1\n \n \n test2\n 2\n \n\n" + body: "test11test22" ).to_return(status: 200, body: <<~XML) From 0f0388437557553c2e28c04c471e997ff5fe645c Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 21 Oct 2021 09:02:35 +1000 Subject: [PATCH 002/254] DEV: Refactor bookmark modal code (#14654) We had code to open the bookmark modal in two places -- the bookmark list and also from within a topic. This caused the two code paths to drift, as in the bookmark list we were not passing in the forTopic or autoDeletePreferences data into the modal, and we were also not refreshing the bookmark list when the bookmark was deleted from within the modal. This commit moves the modal opening code into an importable function from the controllers/bookmark module, and all callers have to do is pass it an instance of Bookmark and also options for what to do for the following: * onAfterSave * onAfterDelete * onCloseWithoutSaving --- .../discourse/app/components/bookmark-list.js | 17 ++--- .../discourse/app/controllers/bookmark.js | 55 ++++++++++++++ .../discourse/app/controllers/topic.js | 74 ++++--------------- 3 files changed, 78 insertions(+), 68 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/bookmark-list.js b/app/assets/javascripts/discourse/app/components/bookmark-list.js index 30b7197c4c..7272ab7381 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark-list.js +++ b/app/assets/javascripts/discourse/app/components/bookmark-list.js @@ -7,7 +7,7 @@ import I18n from "I18n"; import { Promise } from "rsvp"; import { action } from "@ember/object"; import bootbox from "bootbox"; -import showModal from "discourse/lib/show-modal"; +import { openBookmarkModal } from "discourse/controllers/bookmark"; export default Component.extend({ classNames: ["bookmark-list-wrapper"], @@ -51,17 +51,14 @@ export default Component.extend({ @action editBookmark(bookmark) { - let controller = showModal("bookmark", { - model: { - postId: bookmark.post_id, - id: bookmark.id, - reminderAt: bookmark.reminder_at, - name: bookmark.name, + openBookmarkModal(bookmark, { + onAfterSave: () => { + this.reload(); + }, + onAfterDelete: () => { + this.reload(); }, - title: "post.bookmarks.edit", - modalClass: "bookmark-with-reminder", }); - controller.set("afterSave", this.reload); }, @action diff --git a/app/assets/javascripts/discourse/app/controllers/bookmark.js b/app/assets/javascripts/discourse/app/controllers/bookmark.js index 6b57d740c7..b47b83b951 100644 --- a/app/assets/javascripts/discourse/app/controllers/bookmark.js +++ b/app/assets/javascripts/discourse/app/controllers/bookmark.js @@ -1,6 +1,61 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { action } from "@ember/object"; +import { Promise } from "rsvp"; +import showModal from "discourse/lib/show-modal"; + +export function openBookmarkModal( + bookmark, + callbacks = { + onCloseWithoutSaving: null, + onAfterSave: null, + onAfterDelete: null, + } +) { + return new Promise((resolve) => { + const modalTitle = () => { + if (bookmark.for_topic) { + return bookmark.id + ? "post.bookmarks.edit_for_topic" + : "post.bookmarks.create_for_topic"; + } + return bookmark.id ? "post.bookmarks.edit" : "post.bookmarks.create"; + }; + let modalController = showModal("bookmark", { + model: { + postId: bookmark.post_id, + id: bookmark.id, + reminderAt: bookmark.reminder_at, + autoDeletePreference: bookmark.auto_delete_preference, + name: bookmark.name, + forTopic: bookmark.for_topic, + }, + title: modalTitle(), + modalClass: "bookmark-with-reminder", + }); + modalController.setProperties({ + onCloseWithoutSaving: () => { + if (callbacks.onCloseWithoutSaving) { + callbacks.onCloseWithoutSaving(); + } + resolve(); + }, + afterSave: (savedData) => { + let resolveData; + if (callbacks.onAfterSave) { + resolveData = callbacks.onAfterSave(savedData); + } + resolve(resolveData); + }, + afterDelete: (topicBookmarked, bookmarkId) => { + if (callbacks.onAfterDelete) { + callbacks.onAfterDelete(topicBookmarked, bookmarkId); + } + resolve(); + }, + }); + }); +} export default Controller.extend(ModalFunctionality, { onShow() { diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index a0e01822e4..5ebaa903e2 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -4,7 +4,7 @@ import { alias, and, not, or } from "@ember/object/computed"; import discourseComputed, { observes } from "discourse-common/utils/decorators"; import { isEmpty, isPresent } from "@ember/utils"; import { later, next, schedule } from "@ember/runloop"; -import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; +import Bookmark, { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; import Composer from "discourse/models/composer"; import EmberObject, { action } from "@ember/object"; import I18n from "I18n"; @@ -26,6 +26,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { inject as service } from "@ember/service"; import showModal from "discourse/lib/show-modal"; import { spinnerHTML } from "discourse/helpers/loading-spinner"; +import { openBookmarkModal } from "discourse/controllers/bookmark"; let customPostMessageCallbacks = {}; @@ -1223,86 +1224,43 @@ export default Controller.extend(bufferedProperty("model"), { }, _modifyTopicBookmark(bookmark) { - const title = bookmark.id - ? "post.bookmarks.edit_for_topic" - : "post.bookmarks.create_for_topic"; - return this._openBookmarkModal(bookmark, title, { - onAfterSave: () => { + bookmark = Bookmark.create(bookmark); + return openBookmarkModal(bookmark, { + onAfterSave: (savedData) => { + this._syncBookmarks(savedData); + this.model.set("bookmarking", false); this.model.set("bookmarked", true); this.model.incrementProperty("bookmarksWereChanged"); this.appEvents.trigger("topic:bookmark-toggled"); }, + onAfterDelete: (topicBookmarked, bookmarkId) => { + this.model.removeBookmark(bookmarkId); + }, }); }, _modifyPostBookmark(bookmark, post) { - const title = bookmark.id ? "post.bookmarks.edit" : "post.bookmarks.create"; - return this._openBookmarkModal(bookmark, title, { + bookmark = Bookmark.create(bookmark); + return openBookmarkModal(bookmark, { onCloseWithoutSaving: () => { post.appEvents.trigger("post-stream:refresh", { id: bookmark.post_id, }); }, onAfterSave: (savedData) => { + this._syncBookmarks(savedData); + this.model.set("bookmarking", false); post.createBookmark(savedData); this.model.afterPostBookmarked(post, savedData); return [post.id]; }, - onAfterDelete: (topicBookmarked) => { + onAfterDelete: (topicBookmarked, bookmarkId) => { + this.model.removeBookmark(bookmarkId); post.deleteBookmark(topicBookmarked); }, }); }, - _openBookmarkModal( - bookmark, - title, - callbacks = { - onCloseWithoutSaving: null, - onAfterSave: null, - onAfterDelete: null, - } - ) { - return new Promise((resolve) => { - let modalController = showModal("bookmark", { - model: { - postId: bookmark.post_id, - id: bookmark.id, - reminderAt: bookmark.reminder_at, - autoDeletePreference: bookmark.auto_delete_preference, - name: bookmark.name, - forTopic: bookmark.for_topic, - }, - title, - modalClass: "bookmark-with-reminder", - }); - modalController.setProperties({ - onCloseWithoutSaving: () => { - if (callbacks.onCloseWithoutSaving) { - callbacks.onCloseWithoutSaving(); - } - resolve(); - }, - afterSave: (savedData) => { - this._syncBookmarks(savedData); - this.model.set("bookmarking", false); - let resolveData; - if (callbacks.onAfterSave) { - resolveData = callbacks.onAfterSave(savedData); - } - resolve(resolveData); - }, - afterDelete: (topicBookmarked, bookmarkId) => { - this.model.removeBookmark(bookmarkId); - if (callbacks.onAfterDelete) { - callbacks.onAfterDelete(topicBookmarked); - } - resolve(); - }, - }); - }); - }, - _syncBookmarks(data) { if (!this.model.bookmarks) { this.model.set("bookmarks", []); From f00053e414ed4cfb65b6aa0dbb0654681a83a853 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Oct 2021 10:46:32 +0800 Subject: [PATCH 003/254] Build(deps): Bump zeitwerk from 2.4.2 to 2.5.0 (#14670) Bumps [zeitwerk](https://github.com/fxn/zeitwerk) from 2.4.2 to 2.5.0. - [Release notes](https://github.com/fxn/zeitwerk/releases) - [Changelog](https://github.com/fxn/zeitwerk/blob/main/CHANGELOG.md) - [Commits](https://github.com/fxn/zeitwerk/compare/v2.4.2...v2.5.0) --- updated-dependencies: - dependency-name: zeitwerk dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0e75936277..9b0ed5b9f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -473,7 +473,7 @@ GEM jwt (~> 2.0) xorcist (1.1.2) yaml-lint (0.0.10) - zeitwerk (2.4.2) + zeitwerk (2.5.0) PLATFORMS arm64-darwin-20 From f60711488f520d376ac86a42ddfabb6982402486 Mon Sep 17 00:00:00 2001 From: Gavin Date: Thu, 21 Oct 2021 04:47:06 +0200 Subject: [PATCH 004/254] UPDATE: correct path to messages (#14668) --- config/locales/client.en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2054b24c4d..d3690731d2 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1219,7 +1219,7 @@ en: tags: "Tags" warnings: "Official Warnings" read_more_in_group: "Want to read more? Browse other messages in %{groupLink}." - read_more: "Want to read more? Browse other messages in personal messages." + read_more: "Want to read more? Browse other messages in personal messages." read_more_group_pm_MF: "There { UNREAD, plural, @@ -1249,7 +1249,7 @@ en: =0 {} one { {BOTH, select, true{and } false {is } other{}} # new message} other { {BOTH, select, true{and } false {are } other{}} # new messages} - } remaining, or browse other personal messages" + } remaining, or browse other personal messages" preferences_nav: account: "Account" From a9d6b2380230a0ee497f4fd993b4c42107f1f75e Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 21 Oct 2021 10:47:22 +0800 Subject: [PATCH 005/254] DEV: Remove code that is no longer valid. (#14657) The vote post action type was removed in 96aca6d7e6dc3c05d761098a19f96b82d2783b1c Follow-up to 96aca6d7e6dc3c05d761098a19f96b82d2783b1c. --- app/serializers/post_serializer.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 8899ed0731..ec1f656a77 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -284,7 +284,6 @@ class PostSerializer < BasicPostSerializer count = object.public_send(count_col) if object.respond_to?(count_col) summary = { id: id, count: count } - summary[:hidden] = true if sym == :vote if scope.post_can_act?(object, sym, opts: { taken_actions: actions }, can_see_post: can_see_post) summary[:can_act] = true From 80ec6f09d375f4c5095b9f87fadeb7f733d29fef Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 21 Oct 2021 10:06:31 +0200 Subject: [PATCH 006/254] DEV: removes unnecessary caret position code (#14665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't support any browser needing this for very long: https://caniuse.com/?search=selectionStart I'm keeping some protection so It doesn’t crash but ultimately `element.selectionStart` should be enough. Im not removing this in the commit, but the `caret_position.js` file seems barely used. --- .../discourse/app/lib/utilities.js | 19 +------------------ vendor/assets/javascripts/caret_position.js | 11 ----------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index 654eaeb353..980ea245b8 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -224,24 +224,7 @@ export function caretRowCol(el) { // Determine the position of the caret in an element export function caretPosition(el) { - let r, rc, re; - if (el.selectionStart) { - return el.selectionStart; - } - if (document.selection) { - el.focus(); - r = document.selection.createRange(); - if (!r) { - return 0; - } - - re = el.createTextRange(); - rc = re.duplicate(); - re.moveToBookmark(r.getBookmark()); - rc.setEndPoint("EndToStart", re); - return rc.text.length; - } - return 0; + return el?.selectionStart || 0; } // Set the caret's position diff --git a/vendor/assets/javascripts/caret_position.js b/vendor/assets/javascripts/caret_position.js index 2393c6f406..3a7ca2cee2 100644 --- a/vendor/assets/javascripts/caret_position.js +++ b/vendor/assets/javascripts/caret_position.js @@ -6,22 +6,11 @@ var clone = null; $.fn.caret = function(elem) { var getCaret = function(el) { - var r, rc, re; if (el.selectionStart) { return el.selectionStart; - } else if (document.selection) { - el.focus(); - r = document.selection.createRange(); - if (!r) return 0; - re = el.createTextRange(); - rc = re.duplicate(); - re.moveToBookmark(r.getBookmark()); - rc.setEndPoint("EndToStart", re); - return rc.text.length; } return 0; }; - return getCaret(elem || this[0]); }; From b57b079ff278d5f9b6ea5134acc2b4b0db9fba7d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 21 Oct 2021 12:42:46 +0100 Subject: [PATCH 007/254] DEV: Update discourse-presence plugin to use new PresenceChannel system (#14519) This removes all custom controllers and redis/messagebus logic from discourse-presence, and replaces it with core's new PresenceChannel system. All functionality should be retained. This implementation should scale much better to large numbers of users, reduce the number of HTTP requests made by clients, and reduce the volume of messages on the MessageBus. For more information on PresenceChannel, see 31db8352 --- .../discourse/app/services/presence.js | 15 +- lib/presence_channel.rb | 4 +- plugins/discourse-presence/README.md | 12 - .../composer-presence-display.js.es6 | 173 +++---- .../components/topic-presence-display.js.es6 | 58 ++- .../javascripts/discourse/lib/presence.js.es6 | 229 --------- .../services/composer-presence-manager.js | 64 +++ .../services/presence-manager.js.es6 | 82 --- .../composer-fields/presence.js.es6 | 5 - .../topic-above-footer-buttons/presence.hbs | 1 + .../presence.js.es6 | 5 - plugins/discourse-presence/plugin.rb | 200 ++------ .../spec/integration/presence_spec.rb | 193 +++++++ .../spec/requests/presence_controller_spec.rb | 472 ------------------ .../acceptance/discourse-presence-test.js | 231 +++++++++ 15 files changed, 673 insertions(+), 1071 deletions(-) delete mode 100644 plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 create mode 100644 plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js delete mode 100644 plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 delete mode 100644 plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 delete mode 100644 plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 create mode 100644 plugins/discourse-presence/spec/integration/presence_spec.rb delete mode 100644 plugins/discourse-presence/spec/requests/presence_controller_spec.rb create mode 100644 plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index ac1ba6d3af..346dcef1af 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -2,7 +2,15 @@ import Service from "@ember/service"; import EmberObject, { computed, defineProperty } from "@ember/object"; import { readOnly } from "@ember/object/computed"; import { ajax } from "discourse/lib/ajax"; -import { cancel, debounce, later, next, once, throttle } from "@ember/runloop"; +import { + cancel, + debounce, + later, + next, + once, + run, + throttle, +} from "@ember/runloop"; import Session from "discourse/models/session"; import { Promise } from "rsvp"; import { isLegacyEmber, isTesting } from "discourse-common/config/environment"; @@ -137,9 +145,8 @@ class PresenceChannelState extends EmberObject { this.lastSeenId = initialData.last_message_id; - let callback = (data, global_id, message_id) => { - this._processMessage(data, global_id, message_id); - }; + let callback = (data, global_id, message_id) => + run(() => this._processMessage(data, global_id, message_id)); this.presenceService.messageBus.subscribe( `/presence${this.name}`, callback, diff --git a/lib/presence_channel.rb b/lib/presence_channel.rb index 531e861973..a84853465b 100644 --- a/lib/presence_channel.rb +++ b/lib/presence_channel.rb @@ -61,7 +61,7 @@ class PresenceChannel end DEFAULT_TIMEOUT ||= 60 - CONFIG_CACHE_SECONDS ||= 120 + CONFIG_CACHE_SECONDS ||= 10 GC_SECONDS ||= 24.hours.to_i MUTEX_TIMEOUT_SECONDS ||= 10 MUTEX_LOCKED_ERROR ||= "PresenceChannel mutex is locked" @@ -281,7 +281,7 @@ class PresenceChannel # should not exist, the block should return `nil`. If the channel should exist, # the block should return a PresenceChannel::Config object. # - # Return values may be cached for up to 2 minutes. + # Return values may be cached for up to 10 seconds. # # Plugins should use the {Plugin::Instance.register_presence_channel_prefix} API instead def self.register_prefix(prefix, &block) diff --git a/plugins/discourse-presence/README.md b/plugins/discourse-presence/README.md index 4e41c6c62e..64be78e1ca 100644 --- a/plugins/discourse-presence/README.md +++ b/plugins/discourse-presence/README.md @@ -1,14 +1,2 @@ # Discourse Presence plugin This plugin shows which users are currently writing a reply at the same time as you. - -## Installation - -Follow the directions at [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) using https://github.com/discourse/discourse-presence.git as the repository URL. - -## Authors - -André Pereira, David Taylor - -## License - -GNU GPL v2 diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 index 19a82d0309..6e3343800c 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 @@ -1,117 +1,108 @@ -import { - CLOSED, - COMPOSER_TYPE, - EDITING, - KEEP_ALIVE_DURATION_SECONDS, - REPLYING, -} from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import { cancel, throttle } from "@ember/runloop"; import discourseComputed, { observes, on, } from "discourse-common/utils/decorators"; -import { gt, readOnly } from "@ember/object/computed"; +import { equal, gt, readOnly, union } from "@ember/object/computed"; import Component from "@ember/component"; import { inject as service } from "@ember/service"; export default Component.extend({ - // Passed in variables - presenceManager: service(), - - @discourseComputed("model.topic.id") - users(topicId) { - return this.presenceManager.users(topicId); - }, - - @discourseComputed("model.topic.id") - editingUsers(topicId) { - return this.presenceManager.editingUsers(topicId); - }, - - isReply: readOnly("model.replyingToTopic"), - isEdit: readOnly("model.editingPost"), - - @on("didInsertElement") - subscribe() { - this.presenceManager.subscribe(this.get("model.topic.id"), COMPOSER_TYPE); - }, + presence: service(), + composerPresenceManager: service(), @discourseComputed( - "model.post.id", - "editingUsers.@each.last_seen", - "users.@each.last_seen", - "isReply", - "isEdit" + "model.replyingToTopic", + "model.editingPost", + "model.whisper", + "model.composerOpened", + "isDestroying" ) - presenceUsers(postId, editingUsers, users, isReply, isEdit) { - if (isEdit) { - return editingUsers.filterBy("post_id", postId); - } else if (isReply) { - return users; + state(replyingToTopic, editingPost, whisper, composerOpen, isDestroying) { + if (!composerOpen || isDestroying) { + return; + } else if (editingPost) { + return "edit"; + } else if (whisper) { + return "whisper"; + } else if (replyingToTopic) { + return "reply"; } - return []; + }, + + isReply: equal("state", "reply"), + isEdit: equal("state", "edit"), + isWhisper: equal("state", "whisper"), + + @discourseComputed("model.topic.id", "isReply", "isWhisper") + replyChannelName(topicId, isReply, isWhisper) { + if (topicId && (isReply || isWhisper)) { + return `/discourse-presence/reply/${topicId}`; + } + }, + + @discourseComputed("model.topic.id", "isReply", "isWhisper") + whisperChannelName(topicId, isReply, isWhisper) { + if (topicId && this.currentUser.staff && (isReply || isWhisper)) { + return `/discourse-presence/whisper/${topicId}`; + } + }, + + @discourseComputed("isEdit", "model.post.id") + editChannelName(isEdit, postId) { + if (isEdit) { + return `/discourse-presence/edit/${postId}`; + } + }, + + _setupChannel(channelKey, name) { + if (this[channelKey]?.name !== name) { + this[channelKey]?.unsubscribe(); + if (name) { + this.set(channelKey, this.presence.getChannel(name)); + this[channelKey].subscribe(); + } else if (this[channelKey]) { + this.set(channelKey, null); + } + } + }, + + @observes("replyChannelName", "whisperChannelName", "editChannelName") + _setupChannels() { + this._setupChannel("replyChannel", this.replyChannelName); + this._setupChannel("whisperChannel", this.whisperChannelName); + this._setupChannel("editChannel", this.editChannelName); + }, + + replyingUsers: union("replyChannel.users", "whisperChannel.users"), + editingUsers: readOnly("editChannel.users"), + + @discourseComputed("isReply", "replyingUsers.[]", "editingUsers.[]") + presenceUsers(isReply, replyingUsers, editingUsers) { + const users = isReply ? replyingUsers : editingUsers; + return users + ?.filter((u) => u.id !== this.currentUser.id) + ?.slice(0, this.siteSettings.presence_max_users_shown); }, shouldDisplay: gt("presenceUsers.length", 0), - @observes("model.reply", "model.title") - typing() { - throttle(this, this._typing, KEEP_ALIVE_DURATION_SECONDS * 1000); + @on("didInsertElement") + subscribe() { + this._setupChannels(); }, - _typing() { - if ((!this.isReply && !this.isEdit) || !this.get("model.composerOpened")) { + @observes("model.reply", "state", "model.post.id", "model.topic.id") + _contentChanged() { + if (this.model.reply === "") { return; } - - let data = { - topicId: this.get("model.topic.id"), - state: this.isEdit ? EDITING : REPLYING, - whisper: this.get("model.whisper"), - postId: this.get("model.post.id"), - presenceStaffOnly: this.get("model._presenceStaffOnly"), - }; - - this._prevPublishData = data; - - this._throttle = this.presenceManager.publish( - data.topicId, - data.state, - data.whisper, - data.postId, - data.presenceStaffOnly - ); - }, - - @observes("model.whisper") - cancelThrottle() { - this._cancelThrottle(); - }, - - @observes("model.action", "model.topic.id") - composerState() { - if (this._prevPublishData) { - this.presenceManager.publish( - this._prevPublishData.topicId, - CLOSED, - this._prevPublishData.whisper, - this._prevPublishData.postId - ); - this._prevPublishData = null; - } + const entity = this.state === "edit" ? this.model?.post : this.model?.topic; + this.composerPresenceManager.notifyState(this.state, entity?.id); }, @on("willDestroyElement") closeComposer() { - this._cancelThrottle(); - this._prevPublishData = null; - this.presenceManager.cleanUpPresence(COMPOSER_TYPE); - }, - - _cancelThrottle() { - if (this._throttle) { - cancel(this._throttle); - this._throttle = null; - } + this._setupChannels(); + this.composerPresenceManager.leave(); }, }); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 index f38ac5c582..42e504cee7 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 @@ -1,37 +1,63 @@ import discourseComputed, { on } from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import { TOPIC_TYPE } from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import { gt } from "@ember/object/computed"; +import { gt, union } from "@ember/object/computed"; import { inject as service } from "@ember/service"; export default Component.extend({ topic: null, - topicId: null, - presenceManager: service(), + presence: service(), + replyChannel: null, + whisperChannel: null, + + @discourseComputed("replyChannel.users.[]") + replyUsers(users) { + return users?.filter((u) => u.id !== this.currentUser.id); + }, + + @discourseComputed("whisperChannel.users.[]") + whisperUsers(users) { + return users?.filter((u) => u.id !== this.currentUser.id); + }, + + users: union("replyUsers", "whisperUsers"), @discourseComputed("topic.id") - users(topicId) { - return this.presenceManager.users(topicId); + replyChannelName(id) { + return `/discourse-presence/reply/${id}`; + }, + + @discourseComputed("topic.id") + whisperChannelName(id) { + return `/discourse-presence/whisper/${id}`; }, shouldDisplay: gt("users.length", 0), didReceiveAttrs() { this._super(...arguments); - if (this.topicId) { - this.presenceManager.unsubscribe(this.topicId, TOPIC_TYPE); - } - this.set("topicId", this.get("topic.id")); - }, - @on("didInsertElement") - subscribe() { - this.set("topicId", this.get("topic.id")); - this.presenceManager.subscribe(this.get("topic.id"), TOPIC_TYPE); + if (this.replyChannel?.name !== this.replyChannelName) { + this.replyChannel?.unsubscribe(); + this.set("replyChannel", this.presence.getChannel(this.replyChannelName)); + this.replyChannel.subscribe(); + } + + if ( + this.currentUser.staff && + this.whisperChannel?.name !== this.whisperChannelName + ) { + this.whisperChannel?.unsubscribe(); + this.set( + "whisperChannel", + this.presence.getChannel(this.whisperChannelName) + ); + this.whisperChannel.subscribe(); + } }, @on("willDestroyElement") _destroyed() { - this.presenceManager.unsubscribe(this.get("topic.id"), TOPIC_TYPE); + this.replyChannel?.unsubscribe(); + this.whisperChannel?.unsubscribe(); }, }); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 deleted file mode 100644 index 7db5048f67..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 +++ /dev/null @@ -1,229 +0,0 @@ -import { cancel, later } from "@ember/runloop"; -import EmberObject from "@ember/object"; -import { ajax } from "discourse/lib/ajax"; -import discourseComputed from "discourse-common/utils/decorators"; - -// The durations chosen here determines the accuracy of the presence feature and -// is tied closely with the server side implementation. Decreasing the duration -// to increase the accuracy will come at the expense of having to more network -// calls to publish the client's state. -// -// Logic walk through of our heuristic implementation: -// - When client A is typing, a message is published every KEEP_ALIVE_DURATION_SECONDS. -// - Client B receives the message and stores each user in an array and marks -// the user with a client-side timestamp of when the user was seen. -// - If client A continues to type, client B will continue to receive messages to -// update the client-side timestamp of when client A was last seen. -// - If client A disconnects or becomes inactive, the state of client A will be -// cleaned up on client B by a scheduler that runs every TIMER_INTERVAL_MILLISECONDS -export const KEEP_ALIVE_DURATION_SECONDS = 10; -const BUFFER_DURATION_SECONDS = KEEP_ALIVE_DURATION_SECONDS + 2; - -const MESSAGE_BUS_LAST_ID = 0; -const TIMER_INTERVAL_MILLISECONDS = 2000; - -export const REPLYING = "replying"; -export const EDITING = "editing"; -export const CLOSED = "closed"; - -export const TOPIC_TYPE = "topic"; -export const COMPOSER_TYPE = "composer"; - -const Presence = EmberObject.extend({ - users: null, - editingUsers: null, - subscribers: null, - topicId: null, - currentUser: null, - messageBus: null, - siteSettings: null, - - init() { - this._super(...arguments); - - this.setProperties({ - users: [], - editingUsers: [], - subscribers: new Set(), - }); - }, - - subscribe(type) { - if (this.subscribers.size === 0) { - this.messageBus.subscribe( - this.channel, - (message) => { - const { user, state } = message; - if (this.get("currentUser.id") === user.id) { - return; - } - - switch (state) { - case REPLYING: - this._appendUser(this.users, user); - break; - case EDITING: - this._appendUser(this.editingUsers, user, { - post_id: parseInt(message.post_id, 10), - }); - break; - case CLOSED: - this._removeUser(user); - break; - } - }, - MESSAGE_BUS_LAST_ID - ); - } - - this.subscribers.add(type); - }, - - unsubscribe(type) { - this.subscribers.delete(type); - const noSubscribers = this.subscribers.size === 0; - - if (noSubscribers) { - this.messageBus.unsubscribe(this.channel); - this._stopTimer(); - - this.setProperties({ - users: [], - editingUsers: [], - }); - } - - return noSubscribers; - }, - - @discourseComputed("topicId") - channel(topicId) { - return `/presence-plugin/${topicId}`; - }, - - publish(state, whisper, postId, staffOnly) { - // NOTE: `user_option` is the correct place to get this value from, but - // it may not have been set yet. It will always have been set directly - // on the currentUser, via the preloaded_json payload. - // TODO: Remove this when preloaded_json is refactored. - let hiddenProfile = this.get( - "currentUser.user_option.hide_profile_and_presence" - ); - if (hiddenProfile === undefined) { - hiddenProfile = this.get("currentUser.hide_profile_and_presence"); - } - - if (hiddenProfile && this.get("siteSettings.allow_users_to_hide_profile")) { - return; - } - - const data = { - state, - topic_id: this.topicId, - }; - - if (whisper) { - data.is_whisper = true; - } - - if (postId && state === EDITING) { - data.post_id = postId; - } - - if (staffOnly) { - data.staff_only = true; - } - - return ajax("/presence-plugin/publish", { - type: "POST", - data, - }); - }, - - _removeUser(user) { - [this.users, this.editingUsers].forEach((users) => { - const existingUser = users.findBy("id", user.id); - if (existingUser) { - users.removeObject(existingUser); - } - }); - }, - - _cleanUpUsers() { - [this.users, this.editingUsers].forEach((users) => { - const staleUsers = []; - - users.forEach((user) => { - if (user.last_seen <= Date.now() - BUFFER_DURATION_SECONDS * 1000) { - staleUsers.push(user); - } - }); - - users.removeObjects(staleUsers); - }); - - return this.users.length === 0 && this.editingUsers.length === 0; - }, - - _appendUser(users, user, attrs) { - let existingUser; - let usersLength = 0; - - users.forEach((u) => { - if (u.id === user.id) { - existingUser = u; - } - - if (attrs && attrs.post_id) { - if (u.post_id === attrs.post_id) { - usersLength++; - } - } else { - usersLength++; - } - }); - - const props = attrs || {}; - props.last_seen = Date.now(); - - if (existingUser) { - existingUser.setProperties(props); - } else { - const limit = this.get("siteSettings.presence_max_users_shown"); - - if (usersLength < limit) { - users.pushObject(EmberObject.create(Object.assign(user, props))); - } - } - - this._startTimer(() => { - this._cleanUpUsers(); - }); - }, - - _scheduleTimer(callback) { - return later( - this, - () => { - const stop = callback(); - - if (!stop) { - this.set("_timer", this._scheduleTimer(callback)); - } - }, - TIMER_INTERVAL_MILLISECONDS - ); - }, - - _stopTimer() { - cancel(this._timer); - }, - - _startTimer(callback) { - if (!this._timer) { - this.set("_timer", this._scheduleTimer(callback)); - } - }, -}); - -export default Presence; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js b/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js new file mode 100644 index 0000000000..e302a3a585 --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js @@ -0,0 +1,64 @@ +import Service, { inject as service } from "@ember/service"; +import { cancel, debounce } from "@ember/runloop"; +import { isTesting } from "discourse-common/config/environment"; + +const PRESENCE_CHANNEL_PREFIX = "/discourse-presence"; +const KEEP_ALIVE_DURATION_SECONDS = 10; + +export default class ComposerPresenceManager extends Service { + @service presence; + + notifyState(intent, id) { + if ( + this.siteSettings.allow_users_to_hide_profile && + this.currentUser.hide_profile_and_presence + ) { + return; + } + + if (intent === undefined) { + return this.leave(); + } + + if (!["reply", "whisper", "edit"].includes(intent)) { + throw `Unknown intent ${intent}`; + } + + const state = `${intent}/${id}`; + + if (this._state !== state) { + this._enter(intent, id); + this._state = state; + } + + if (!isTesting()) { + this._autoLeaveTimer = debounce( + this, + this.leave, + KEEP_ALIVE_DURATION_SECONDS * 1000 + ); + } + } + + leave() { + this._presentChannel?.leave(); + this._presentChannel = null; + this._state = null; + if (this._autoLeaveTimer) { + cancel(this._autoLeaveTimer); + this._autoLeaveTimer = null; + } + } + + _enter(intent, id) { + this.leave(); + + let channelName = `${PRESENCE_CHANNEL_PREFIX}/${intent}/${id}`; + this._presentChannel = this.presence.getChannel(channelName); + this._presentChannel.enter(); + } + + willDestroy() { + this.leave(); + } +} diff --git a/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 deleted file mode 100644 index ae24b63073..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 +++ /dev/null @@ -1,82 +0,0 @@ -import Presence, { - CLOSED, -} from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import Service from "@ember/service"; - -const PresenceManager = Service.extend({ - presences: null, - - init() { - this._super(...arguments); - - this.setProperties({ - presences: {}, - }); - }, - - subscribe(topicId, type) { - if (!topicId) { - return; - } - this._getPresence(topicId).subscribe(type); - }, - - unsubscribe(topicId, type) { - if (!topicId) { - return; - } - const presence = this._getPresence(topicId); - - if (presence.unsubscribe(type)) { - delete this.presences[topicId]; - } - }, - - users(topicId) { - if (!topicId) { - return []; - } - return this._getPresence(topicId).users; - }, - - editingUsers(topicId) { - if (!topicId) { - return []; - } - return this._getPresence(topicId).editingUsers; - }, - - publish(topicId, state, whisper, postId, staffOnly) { - if (!topicId) { - return; - } - return this._getPresence(topicId).publish( - state, - whisper, - postId, - staffOnly - ); - }, - - cleanUpPresence(type) { - Object.keys(this.presences).forEach((key) => { - this.publish(key, CLOSED); - this.unsubscribe(key, type); - }); - }, - - _getPresence(topicId) { - if (!this.presences[topicId]) { - this.presences[topicId] = Presence.create({ - messageBus: this.messageBus, - siteSettings: this.siteSettings, - currentUser: this.currentUser, - topicId, - }); - } - - return this.presences[topicId]; - }, -}); - -export default PresenceManager; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 deleted file mode 100644 index 75ca86b4a4..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default { - shouldRender(_, component) { - return component.siteSettings.presence_enabled; - }, -}; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs index c8514c7edc..5b76786960 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs +++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs @@ -1 +1,2 @@ +{{!-- Note: the topic-above-footer-buttons outlet is only rendered for logged-in users --}} {{topic-presence-display topic=model}} diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 deleted file mode 100644 index 75ca86b4a4..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default { - shouldRender(_, component) { - return component.siteSettings.presence_enabled; - }, -}; diff --git a/plugins/discourse-presence/plugin.rb b/plugins/discourse-presence/plugin.rb index d20f4a2b1d..6001eaa071 100644 --- a/plugins/discourse-presence/plugin.rb +++ b/plugins/discourse-presence/plugin.rb @@ -1,178 +1,72 @@ # frozen_string_literal: true # name: discourse-presence -# about: Show which users are writing a reply to a topic +# about: Show which users are replying to a topic, or editing a post # version: 2.0 # authors: André Pereira, David Taylor, tgxworld # url: https://github.com/discourse/discourse/tree/main/plugins/discourse-presence +# transpile_js: true enabled_site_setting :presence_enabled hide_plugin if self.respond_to?(:hide_plugin) register_asset 'stylesheets/presence.scss' -PLUGIN_NAME ||= -"discourse-presence" - after_initialize do - MessageBus.register_client_message_filter('/presence-plugin/') do |message| - published_at = message.data["published_at"] + register_presence_channel_prefix("discourse-presence") do |channel_name| + if topic_id = channel_name[/\/discourse-presence\/reply\/(\d+)/, 1] + topic = Topic.find(topic_id) + config = PresenceChannel::Config.new - if published_at - (Time.zone.now.to_i - published_at) <= ::Presence::MAX_BACKLOG_AGE_SECONDS - else - false - end - end - - module ::Presence - MAX_BACKLOG_AGE_SECONDS = 10 - - class Engine < ::Rails::Engine - engine_name PLUGIN_NAME - isolate_namespace Presence - end - end - - require_dependency "application_controller" - - class Presence::PresencesController < ::ApplicationController - requires_plugin PLUGIN_NAME - before_action :ensure_logged_in - before_action :ensure_presence_enabled - - EDITING_STATE = 'editing' - REPLYING_STATE = 'replying' - CLOSED_STATE = 'closed' - - def handle_message - [:state, :topic_id].each do |key| - raise ActionController::ParameterMissing.new(key) unless params.key?(key) - end - - topic_id = permitted_params[:topic_id] - topic = Topic.find_by(id: topic_id) - - raise Discourse::InvalidParameters.new(:topic_id) unless topic - guardian.ensure_can_see!(topic) - - post = nil - - if (permitted_params[:post_id]) - if (permitted_params[:state] != EDITING_STATE) - raise Discourse::InvalidParameters.new(:state) - end - - post = Post.find_by(id: permitted_params[:post_id]) - raise Discourse::InvalidParameters.new(:topic_id) unless post - - guardian.ensure_can_edit!(post) - end - - opts = { - max_backlog_age: Presence::MAX_BACKLOG_AGE_SECONDS - } - - if permitted_params[:staff_only] - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] + if topic.private_message? + config.allowed_user_ids = topic.allowed_users.pluck(:id) + config.allowed_group_ids = topic.allowed_groups.pluck(:group_id) + [::Group::AUTO_GROUPS[:staff]] + elsif secure_group_ids = topic.secure_group_ids + config.allowed_group_ids = secure_group_ids else - case permitted_params[:state] - when EDITING_STATE - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] - - if !post.locked? && !permitted_params[:is_whisper] - opts[:user_ids] = [post.user_id] - - if topic.private_message? - if post.wiki - opts[:user_ids] = opts[:user_ids].concat( - topic.allowed_users.where( - "trust_level >= ? AND NOT admin OR moderator", - SiteSetting.min_trust_to_edit_wiki_post - ).pluck(:id) - ) - - opts[:user_ids].uniq! - - # Ignore trust level and just publish to all allowed groups since - # trying to figure out which users in the allowed groups have - # the necessary trust levels can lead to a large array of user ids - # if the groups are big. - opts[:group_ids] = opts[:group_ids].concat( - topic.allowed_groups.pluck(:id) - ) - end - else - if post.wiki - opts[:group_ids] << Group::AUTO_GROUPS[:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"] - elsif SiteSetting.trusted_users_can_edit_others? - opts[:group_ids] << Group::AUTO_GROUPS[:trust_level_4] - end - end - end - when REPLYING_STATE - if permitted_params[:is_whisper] - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] - elsif topic.private_message? - opts[:user_ids] = topic.allowed_users.pluck(:id) - - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]].concat( - topic.allowed_groups.pluck(:id) - ) - else - opts[:group_ids] = topic.secure_group_ids - end - when CLOSED_STATE - if topic.private_message? - opts[:user_ids] = topic.allowed_users.pluck(:id) - - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]].concat( - topic.allowed_groups.pluck(:id) - ) - else - opts[:group_ids] = topic.secure_group_ids - end - end + # config.public=true would make data available to anon, so use the tl0 group instead + config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:trust_level_0] ] end - payload = { - user: BasicUserSerializer.new(current_user, root: false).as_json, - state: permitted_params[:state], - is_whisper: permitted_params[:is_whisper].present?, - published_at: Time.zone.now.to_i - } + config + elsif topic_id = channel_name[/\/discourse-presence\/whisper\/(\d+)/, 1] + Topic.find(topic_id) # Just ensure it exists + PresenceChannel::Config.new(allowed_group_ids: [::Group::AUTO_GROUPS[:staff]]) + elsif post_id = channel_name[/\/discourse-presence\/edit\/(\d+)/, 1] + post = Post.find(post_id) + topic = Topic.find(post.topic_id) - if (post_id = permitted_params[:post_id]).present? - payload[:post_id] = post_id + config = PresenceChannel::Config.new + config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:staff] ] + + # Locked and whisper posts are staff only + next config if post.locked? || post.whisper? + + config.allowed_user_ids = [ post.user_id ] + + if topic.private_message? && post.wiki + # Ignore trust level and just publish to all allowed groups since + # trying to figure out which users in the allowed groups have + # the necessary trust levels can lead to a large array of user ids + # if the groups are big. + config.allowed_user_ids += topic.allowed_users.pluck(:id) + config.allowed_group_ids += topic.allowed_groups.pluck(:id) + elsif post.wiki + config.allowed_group_ids << Group::AUTO_GROUPS[:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"] end - MessageBus.publish("/presence-plugin/#{topic_id}", payload, opts) - - render json: success_json - end - - private - - def ensure_presence_enabled - if !SiteSetting.presence_enabled || - (SiteSetting.allow_users_to_hide_profile && - current_user.user_option.hide_profile_and_presence?) - - raise Discourse::NotFound + if !topic.private_message? && SiteSetting.trusted_users_can_edit_others? + config.allowed_group_ids << Group::AUTO_GROUPS[:trust_level_4] end + + if SiteSetting.enable_category_group_moderation? && group_id = topic.category&.reviewable_by_group_id + config.allowed_group_ids << group_id + end + + config end - - def permitted_params - params.permit(:state, :topic_id, :post_id, :is_whisper, :staff_only) - end + rescue ActiveRecord::RecordNotFound + nil end - - Presence::Engine.routes.draw do - post '/publish' => 'presences#handle_message' - end - - Discourse::Application.routes.append do - mount ::Presence::Engine, at: '/presence-plugin' - end - end diff --git a/plugins/discourse-presence/spec/integration/presence_spec.rb b/plugins/discourse-presence/spec/integration/presence_spec.rb new file mode 100644 index 0000000000..2889a7d287 --- /dev/null +++ b/plugins/discourse-presence/spec/integration/presence_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "discourse-presence" do + describe 'PresenceChannel configuration' do + fab!(:user) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + + fab!(:group) do + group = Fabricate(:group) + group.add(user) + group + end + + fab!(:category) { Fabricate(:private_category, group: group) } + fab!(:private_topic) { Fabricate(:topic, category: category) } + fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) } + + fab!(:private_message) do + Fabricate(:private_message_topic, + allowed_groups: [group] + ) + end + + before { PresenceChannel.clear_all! } + + it 'handles invalid topic IDs' do + expect do + PresenceChannel.new('/discourse-presence/reply/-999').config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new('/discourse-presence/reply/blah').config + end.to raise_error(PresenceChannel::NotFound) + end + + it 'handles deleted topics' do + public_topic.trash! + + expect do + PresenceChannel.new("/discourse-presence/reply/#{public_topic.id}").config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new("/discourse-presence/whisper/#{public_topic.id}").config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new("/discourse-presence/edit/#{public_topic.first_post.id}").config + end.to raise_error(PresenceChannel::NotFound) + end + + it 'handles secure category permissions for reply' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}") + expect(c.can_view?(user_id: user.id)).to eq(true) + expect(c.can_enter?(user_id: user.id)).to eq(true) + + group.remove(user) + + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}", use_cache: false) + expect(c.can_view?(user_id: user.id)).to eq(false) + expect(c.can_enter?(user_id: user.id)).to eq(false) + end + + it 'handles secure category permissions for edit' do + p = Fabricate(:post, topic: private_topic, user: private_topic.user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.can_view?(user_id: user.id)).to eq(false) + expect(c.can_view?(user_id: private_topic.user.id)).to eq(true) + end + + it 'handles category moderators for edit' do + SiteSetting.trusted_users_can_edit_others = false + p = Fabricate(:post, topic: private_topic, user: private_topic.user) + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + + SiteSetting.enable_category_group_moderation = true + category.update(reviewable_by_group_id: group.id) + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}", use_cache: false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff], group.id) + end + + it 'handles permissions for a public topic' do + c = PresenceChannel.new("/discourse-presence/reply/#{public_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(::Group::AUTO_GROUPS[:trust_level_0]) + end + + it 'handles permissions for secure category topics' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(group.id) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'handles permissions for private messsages' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_message.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(group.id, Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to contain_exactly( + *private_message.topic_allowed_users.pluck(:user_id) + ) + end + + it "handles permissions for whispers" do + c = PresenceChannel.new("/discourse-presence/whisper/#{public_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'only allows staff when editing whispers' do + p = Fabricate(:whisper, topic: public_topic, user: admin) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'only allows staff when editing a locked post' do + p = Fabricate(:post, topic: public_topic, user: admin, locked_by_id: Discourse.system_user.id) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it "allows author, staff, TL4 when editing a public post" do + p = Fabricate(:post, topic: public_topic, user: user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:trust_level_4], + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "allows only author and staff when editing a public post with tl4 editing disabled" do + SiteSetting.trusted_users_can_edit_others = false + + p = Fabricate(:post, topic: public_topic, user: user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "follows the wiki edit trust level site setting" do + p = Fabricate(:post, topic: public_topic, user: user, wiki: true) + SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] + SiteSetting.trusted_users_can_edit_others = false + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff], + Group::AUTO_GROUPS[:trust_level_1] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "allows author and staff when editing a private message" do + post = Fabricate(:post, topic: private_message, user: user) + + c = PresenceChannel.new("/discourse-presence/edit/#{post.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "includes all message participants for PM wiki" do + post = Fabricate(:post, topic: private_message, user: user, wiki: true) + + c = PresenceChannel.new("/discourse-presence/edit/#{post.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff], + *private_message.allowed_groups.pluck(:id) + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id, *private_message.allowed_users.pluck(:id)) + end + end +end diff --git a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb b/plugins/discourse-presence/spec/requests/presence_controller_spec.rb deleted file mode 100644 index adededeeb3..0000000000 --- a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb +++ /dev/null @@ -1,472 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe ::Presence::PresencesController do - describe '#handle_message' do - context 'when not logged in' do - it 'should raise the right error' do - post '/presence-plugin/publish.json' - - expect(response.status).to eq(403) - end - end - - context 'when logged in' do - fab!(:user) { Fabricate(:user) } - fab!(:user2) { Fabricate(:user) } - fab!(:admin) { Fabricate(:admin) } - - fab!(:group) do - group = Fabricate(:group) - group.add(user) - group - end - - fab!(:category) { Fabricate(:private_category, group: group) } - fab!(:private_topic) { Fabricate(:topic, category: category) } - fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) } - - fab!(:private_message) do - Fabricate(:private_message_topic, - allowed_groups: [group] - ) - end - - before do - sign_in(user) - end - - it 'returns the right response when user disables the presence feature' do - user.user_option.update_column(:hide_profile_and_presence, true) - - post '/presence-plugin/publish.json' - - expect(response.status).to eq(404) - end - - it 'returns the right response when user disables the presence feature and allow_users_to_hide_profile is disabled' do - user.user_option.update_column(:hide_profile_and_presence, true) - SiteSetting.allow_users_to_hide_profile = false - - post '/presence-plugin/publish.json', params: { topic_id: public_topic.id, state: 'replying' } - - expect(response.status).to eq(200) - end - - it 'returns the right response when the presence site settings is disabled' do - SiteSetting.presence_enabled = false - - post '/presence-plugin/publish.json' - - expect(response.status).to eq(404) - end - - it 'returns the right response if required params are missing' do - post '/presence-plugin/publish.json' - - expect(response.status).to eq(400) - end - - it 'returns the right response if topic_id is invalid' do - post '/presence-plugin/publish.json', params: { topic_id: -999, state: 'replying' } - - expect(response.status).to eq(400) - end - - it 'returns the right response when user does not have access to the topic' do - group.remove(user) - - post '/presence-plugin/publish.json', params: { topic_id: private_topic.id, state: 'replying' } - - expect(response.status).to eq(403) - end - - it 'returns the right response when an invalid state is provided with a post_id' do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: public_topic.first_post.id, - state: 'some state' - } - - expect(response.status).to eq(400) - end - - it 'returns the right response when user can not edit a post' do - Fabricate(:post, topic: private_topic, user: private_topic.user) - - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - post_id: private_topic.first_post.id, - state: 'editing' - } - - expect(response.status).to eq(403) - end - - it 'returns the right response when an invalid post_id is given' do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: -9, - state: 'editing' - } - - expect(response.status).to eq(400) - end - - it 'publishes the right message for a public topic' do - freeze_time - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { topic_id: public_topic.id, state: 'replying' } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.channel).to eq("/presence-plugin/#{public_topic.id}") - expect(message.data.dig(:user, :id)).to eq(user.id) - expect(message.data[:published_at]).to eq(Time.zone.now.to_i) - expect(message.group_ids).to eq(nil) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message for a restricted topic' do - freeze_time - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - state: 'replying' - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.channel).to eq("/presence-plugin/#{private_topic.id}") - expect(message.data.dig(:user, :id)).to eq(user.id) - expect(message.data[:published_at]).to eq(Time.zone.now.to_i) - expect(message.group_ids).to contain_exactly(group.id) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message for a private message' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - state: 'replying' - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - group.id, - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly( - *private_message.topic_allowed_users.pluck(:user_id) - ) - end - - it 'publishes the message to staff group when user is whispering' do - SiteSetting.enable_whispers = true - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: 'replying', - is_whisper: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when staff_only param override is present' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: 'replying', - staff_only: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when a staff is editing a whisper' do - SiteSetting.enable_whispers = true - sign_in(admin) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: public_topic.first_post.id, - state: 'editing', - is_whisper: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when a staff is editing a locked post' do - SiteSetting.enable_whispers = true - sign_in(admin) - locked_post = Fabricate(:post, topic: public_topic, locked_by_id: admin.id) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: locked_post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to author, staff group and TL4 group when editing a public post' do - post = Fabricate(:post, topic: public_topic, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:trust_level_4], - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to author and staff group when editing a public post ' \ - 'if SiteSettings.trusted_users_can_edit_others is set to false' do - - post = Fabricate(:post, topic: public_topic, user: user) - SiteSetting.trusted_users_can_edit_others = false - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to SiteSetting.min_trust_to_edit_wiki_post group ' \ - 'and staff group when editing a wiki in a public topic' do - - post = Fabricate(:post, topic: public_topic, user: user, wiki: true) - SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:trust_level_1], - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to author and staff group when editing a private message' do - post = Fabricate(:post, topic: private_message, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to users with trust levels of SiteSetting.min_trust_to_edit_wiki_post ' \ - 'and staff group when editing a wiki in a private message' do - - post = Fabricate(:post, - topic: private_message, - user: private_message.user, - wiki: true - ) - - user2.update!(trust_level: TrustLevel.levels[:newuser]) - group.add(user2) - - SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - group.id - ) - - expect(message.user_ids).to contain_exactly( - *private_message.allowed_users.pluck(:id) - ) - end - - it 'publishes the right message when closing composer in public topic' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to eq(nil) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message when closing composer in private topic' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(group.id) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message when closing composer in private message' do - post = Fabricate(:post, topic: private_message, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - group.id - ) - - expect(message.user_ids).to contain_exactly( - *private_message.allowed_users.pluck(:id) - ) - end - end - end -end diff --git a/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js new file mode 100644 index 0000000000..3b64064c82 --- /dev/null +++ b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js @@ -0,0 +1,231 @@ +import { + acceptance, + count, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { + joinChannel, + leaveChannel, + presentUserIds, +} from "discourse/tests/helpers/presence-pretender"; +import User from "discourse/models/user"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +acceptance("Discourse Presence Plugin", function (needs) { + needs.user(); + needs.settings({ enable_whispers: true }); + + test("Doesn't break topic creation", async function (assert) { + await visit("/"); + await click("#create-topic"); + await fillIn("#reply-title", "Internationalization Localization"); + await fillIn( + ".d-editor-input", + "this is the *content* of a new topic post" + ); + await click("#reply-control button.create"); + + assert.equal( + currentURL(), + "/t/internationalization-localization/280", + "it transitions to the newly created topic URL" + ); + }); + + test("Publishes own reply presence", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "does not publish presence for open composer" + ); + + await fillIn(".d-editor-input", "this is the content of my reply"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [User.current().id], + "publishes presence when typing" + ); + + await click("#reply-control button.create"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "leaves channel when composer closes" + ); + }); + + test("Uses whisper channel for whispers", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + await fillIn(".d-editor-input", "this is the content of my reply"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [User.current().id], + "publishes reply presence when typing" + ); + + const menu = selectKit(".toolbar-popup-menu-options"); + await menu.expand(); + await menu.selectRowByValue("toggleWhisper"); + + assert.equal( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, + "it sets the post type to whisper" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "removes reply presence" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [User.current().id], + "adds whisper presence" + ); + + await click("#reply-control button.create"); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [], + "leaves whisper channel when composer closes" + ); + }); + + test("Uses the edit channel for editing", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); + + assert.equal( + queryAll(".d-editor-input").val(), + queryAll(".topic-post:nth-of-type(1) .cooked > p").text(), + "composer has contents of post to be edited" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/edit/398"), + [], + "is not present when composer first opened" + ); + + await fillIn(".d-editor-input", "some edited content"); + + assert.deepEqual( + presentUserIds("/discourse-presence/edit/398"), + [User.current().id], + "becomes present in the edit channel" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "is not made present in the reply channel" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [], + "is not made present in the whisper channel" + ); + }); + + test("Displays replying and whispering presence at bottom of topic", async function (assert) { + await visit("/t/internationalization-localization/280"); + + const avatarSelector = + ".topic-above-footer-buttons-outlet.presence .presence-avatars .avatar"; + assert.ok( + exists(".topic-above-footer-buttons-outlet.presence"), + "includes the presence component" + ); + assert.equal(count(avatarSelector), 0, "no avatars displayed"); + + await joinChannel("/discourse-presence/reply/280", { + id: 123, + avatar_template: "/a/b/c.jpg", + username: "myusername", + }); + + assert.equal(count(avatarSelector), 1, "avatar displayed"); + + await joinChannel("/discourse-presence/whisper/280", { + id: 124, + avatar_template: "/a/b/c.jpg", + username: "myusername2", + }); + + assert.equal(count(avatarSelector), 2, "whisper avatar displayed"); + + await leaveChannel("/discourse-presence/reply/280", { + id: 123, + }); + + assert.equal(count(avatarSelector), 1, "reply avatar removed"); + + await leaveChannel("/discourse-presence/whisper/280", { + id: 124, + }); + + assert.equal(count(avatarSelector), 0, "whisper avatar removed"); + }); + + test("Displays replying and whispering presence in composer", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + const avatarSelector = + ".composer-fields-outlet.presence .presence-avatars .avatar"; + assert.ok( + exists(".composer-fields-outlet.presence"), + "includes the presence component" + ); + assert.equal(count(avatarSelector), 0, "no avatars displayed"); + + await joinChannel("/discourse-presence/reply/280", { + id: 123, + avatar_template: "/a/b/c.jpg", + username: "myusername", + }); + + assert.equal(count(avatarSelector), 1, "avatar displayed"); + + await joinChannel("/discourse-presence/whisper/280", { + id: 124, + avatar_template: "/a/b/c.jpg", + username: "myusername2", + }); + + assert.equal(count(avatarSelector), 2, "whisper avatar displayed"); + + await leaveChannel("/discourse-presence/reply/280", { + id: 123, + }); + + assert.equal(count(avatarSelector), 1, "reply avatar removed"); + + await leaveChannel("/discourse-presence/whisper/280", { + id: 124, + }); + + assert.equal(count(avatarSelector), 0, "whisper avatar removed"); + }); +}); From e15bd194fde2c5ec21b216e7b594c543e8dc2739 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 20 Oct 2021 15:26:15 -0400 Subject: [PATCH 008/254] UX: Sometimes the footer would show up while refreshing a list route This happens because the state of `canLoadMore` is not cleared as the refresh occurs, which is enough to make the page think a footer should be displayed. No tests here because it's tricky to test refreshing and none of our existing acceptance tests seem to. --- .../javascripts/discourse/app/controllers/discovery/topics.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js index 347c7c3336..b7aefc9587 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js @@ -74,6 +74,7 @@ const controllerOpts = { // router and ember throws an error due to missing `handlerInfos`. // Lesson learned: Don't call `loading` yourself. this.set("discovery.loading", true); + this.set("model.canLoadMore", true); this.topicTrackingState.resetTracking(); From db53c6b24810fa160f59c89f9cba40724cda33f9 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 21 Oct 2021 10:34:50 -0400 Subject: [PATCH 009/254] UX: Add special case for user search when in a topic (#14669) --- .../app/widgets/search-menu-results.js | 39 ++++++++++++++++++- .../discourse/tests/acceptance/search-test.js | 37 ++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index 3aaf47520a..383075d42e 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -380,6 +380,8 @@ createWidget("search-menu-results", { createWidget("search-menu-assistant", { tagName: "ul.search-menu-assistant", + buildKey: () => `search-menu-assistant`, + services: ["router"], html(attrs) { if (this.currentUser) { @@ -434,15 +436,47 @@ createWidget("search-menu-assistant", { }); break; case "@": - attrs.results.forEach((user) => { + // when only one user matches while in topic + // quick suggest user search in the topic or globally + if ( + attrs.results.length === 1 && + this.router.currentRouteName.startsWith("topic.") + ) { + const user = attrs.results[0]; + content.push( + this.attach("search-menu-assistant-item", { + prefix, + user, + setTopicContext: true, + slug: `${prefix}@${user.username}`, + suffix: h( + "span.label-suffix", + ` ${I18n.t("search.in_this_topic")}` + ), + }) + ); content.push( this.attach("search-menu-assistant-item", { prefix, user, slug: `${prefix}@${user.username}`, + suffix: h( + "span.label-suffix", + ` ${I18n.t("search.in_topics_posts")}` + ), }) ); - }); + } else { + attrs.results.forEach((user) => { + content.push( + this.attach("search-menu-assistant-item", { + prefix, + user, + slug: `${prefix}@${user.username}`, + }) + ); + }); + } break; default: suggestionShortcuts.forEach((item) => { @@ -611,6 +645,7 @@ createWidget("search-menu-assistant-item", { username: attrs.user.username, }), h("span.username", formatUsername(attrs.user.username)), + attrs.suffix, ]; content.push(h("span.search-item-user", userResult)); } else { diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index 93787b1a76..673430067a 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -27,6 +27,18 @@ acceptance("Search - Anonymous", function (needs) { } return helper.response(searchFixtures["search/query"]); }); + + server.get("/u/search/users", () => { + return helper.response({ + users: [ + { + username: "admin", + name: "admin", + avatar_template: "/images/avatar.png", + }, + ], + }); + }); }); test("search", async function (assert) { @@ -231,6 +243,31 @@ acceptance("Search - Anonymous", function (needs) { ); }); + test("topic search scope - special case when matching a single user", async function (assert) { + await visit("/t/internationalization-localization/280/1"); + + await click("#search-button"); + await fillIn("#search-term", "@admin"); + + assert.equal(count(".search-menu-assistant-item"), 2); + + assert.equal( + query( + ".search-menu-assistant-item:first-child .search-item-user .label-suffix" + ).textContent.trim(), + I18n.t("search.in_this_topic"), + "first result hints in this topic search" + ); + + assert.equal( + query( + ".search-menu-assistant-item:nth-child(2) .search-item-user .label-suffix" + ).textContent.trim(), + I18n.t("search.in_topics_posts"), + "second result hints global search" + ); + }); + test("Right filters are shown in full page search", async function (assert) { const inSelector = selectKit(".select-kit#in"); From 3b90d7de66178733cbf8cae5a824aceb7ad21350 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 21 Oct 2021 17:00:17 +0200 Subject: [PATCH 010/254] FIX: 2N+1 sql in admin /api/keys endpoint (#14679) --- app/controllers/admin/api_controller.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb index 513aa4b480..e045131c56 100644 --- a/app/controllers/admin/api_controller.rb +++ b/app/controllers/admin/api_controller.rb @@ -5,7 +5,9 @@ class Admin::ApiController < Admin::AdminController # If we used "api_key", then our user provider would try to use the value for authentication def index - keys = ApiKey.where(hidden: false) + keys = ApiKey + .where(hidden: false) + .includes(:user, :api_key_scopes) # Put active keys first # Sort active keys by created_at, sort revoked keys by revoked_at From 70fa67a9e11b1e8d6390f0e86805b8c3ec23a55e Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Thu, 21 Oct 2021 19:43:26 +0300 Subject: [PATCH 011/254] FIX: Don't leak unhashed user API keys to redis (#14682) User API keys (not the same thing as admin API keys) are currently leaked to redis when rate limits are applied to them since redis is the backend for rate limits in Discourse and the API keys are included in the redis keys that are used to track usage of user API keys in the last 24 hours. This commit stops the leak by using a SHA-256 representation of the user API key instead of the key itself to form the redis key. We don't need to manually delete the existing redis keys that contain unhashed user API keys because they're not long-lived and will be automatically deleted within 48 hours after this commit is deployed to your Discourse instance. --- lib/auth/default_current_user_provider.rb | 5 +++-- spec/components/auth/default_current_user_provider_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb index 8c16392b3a..571bad90e7 100644 --- a/lib/auth/default_current_user_provider.rb +++ b/lib/auth/default_current_user_provider.rb @@ -137,8 +137,9 @@ class Auth::DefaultCurrentUserProvider # user api key handling if user_api_key - limiter_min = RateLimiter.new(nil, "user_api_min_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_minute, 60) - limiter_day = RateLimiter.new(nil, "user_api_day_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_day, 86400) + hashed_user_api_key = ApiKey.hash_key(user_api_key) + limiter_min = RateLimiter.new(nil, "user_api_min_#{hashed_user_api_key}", GlobalSetting.max_user_api_reqs_per_minute, 60) + limiter_day = RateLimiter.new(nil, "user_api_day_#{hashed_user_api_key}", GlobalSetting.max_user_api_reqs_per_day, 86400) unless limiter_day.can_perform? limiter_day.performed! diff --git a/spec/components/auth/default_current_user_provider_spec.rb b/spec/components/auth/default_current_user_provider_spec.rb index b8da06fc58..502e289f86 100644 --- a/spec/components/auth/default_current_user_provider_spec.rb +++ b/spec/components/auth/default_current_user_provider_spec.rb @@ -621,8 +621,8 @@ describe Auth::DefaultCurrentUserProvider do end it "rate limits api usage" do - limiter1 = RateLimiter.new(nil, "user_api_day_#{api_key.key}", 10, 60) - limiter2 = RateLimiter.new(nil, "user_api_min_#{api_key.key}", 10, 60) + limiter1 = RateLimiter.new(nil, "user_api_day_#{ApiKey.hash_key(api_key.key)}", 10, 60) + limiter2 = RateLimiter.new(nil, "user_api_min_#{ApiKey.hash_key(api_key.key)}", 10, 60) limiter1.clear! limiter2.clear! From 451cd4ec3fbbc670e44acd72e498ee040f008edf Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 21 Oct 2021 21:01:05 +0200 Subject: [PATCH 012/254] DEV: Fix thor deprecation warning (#14680) ``` Deprecation warning: Thor exit with status 0 on errors. To keep this behavior, you must define `exit_on_failure?` in `DiscourseCLI` ``` --- script/discourse | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/script/discourse b/script/discourse index 1d4e4ff702..bca7197c30 100755 --- a/script/discourse +++ b/script/discourse @@ -4,6 +4,10 @@ require "thor" class DiscourseCLI < Thor + def self.exit_on_failure? + true + end + desc "remap [--global,--regex] FROM TO", "Remap a string sequence across all tables" long_desc <<-LONGDESC Replace a string sequence FROM with TO across all tables. From 7eea58ca9ed5ee0e37f6202ec7fffa64460c8fd0 Mon Sep 17 00:00:00 2001 From: Simon Cossar Date: Thu, 21 Oct 2021 13:15:04 -0700 Subject: [PATCH 013/254] Update copy of the max_new_accounts_per_registration_ip description to indicate that the setting can be disabled (#14687) --- config/locales/server.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index da3b53354a..6f060d4ee2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1950,7 +1950,7 @@ en: user_profile_view_duration_hours: "Count a new user profile view once per IP/User every N hours" levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match." - max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP." + max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP. Set to 0 to disable the limit." min_ban_entries_for_roll_up: "When clicking the Roll up button, will create a new subnet ban entry if there are at least (N) entries." max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days." From 61a7fbdd9e2fe21e70058fd6a5b1a71a969af7e0 Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Thu, 21 Oct 2021 16:32:27 -0400 Subject: [PATCH 014/254] FIX: Correct site setting name (#14686) --- config/locales/server.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6f060d4ee2..ae51ce9838 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1548,7 +1548,7 @@ en: summary_max_results: "Maximum posts returned by 'Summarize This Topic'" summary_timeline_button: "Show a 'Summarize' button in the timeline" - enable_personal_messages: "Allow trust level 1 (configurable via min trust level to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what." + enable_personal_messages: "Allow trust level 1 (configurable via min trust to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what." enable_system_message_replies: "Allows users to reply to system messages, even if personal messages are disabled" enable_long_polling: "Message bus used for notification can use long polling" enable_chunked_encoding: "Enable chunked encoding responses by the server. This feature works on most setups however some proxies may buffer, causing responses to be delayed" From 6192189fd248b1c239ce246faa7b8f83ea14e6c2 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Thu, 21 Oct 2021 10:46:04 -1000 Subject: [PATCH 015/254] DEV: allow composer option to skip jumping to a post on save (#14675) * DEV: allow composer option to skip jumping to a post on save * DEV: refactor js safe access in jump logic Co-authored-by: Jarek Radosz --- .../javascripts/discourse/app/controllers/composer.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 7d4c810fe5..8567c4056c 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -632,7 +632,9 @@ export default Controller.extend({ }, save(ignore, event) { - this.save(false, { jump: !(event && event.shiftKey) }); + this.save(false, { + jump: !event?.shiftKey && !this.skipJumpOnSave, + }); }, displayEditReason() { @@ -956,6 +958,7 @@ export default Controller.extend({ @param {Number} [opts.prioritizedCategoryId] @param {String} [opts.draftSequence] @param {Boolean} [opts.skipDraftCheck] + @param {Boolean} [opts.skipJumpOnSave] Option to skip navigating to the post when saved in this composer session **/ open(opts) { opts = opts || {}; @@ -982,6 +985,8 @@ export default Controller.extend({ skipAutoSave: true, }); + this.set("skipJumpOnSave", !!opts.skipJumpOnSave); + // Scope the categories drop down to the category we opened the composer with. if (opts.categoryId && !opts.disableScopedCategory) { const category = this.site.categories.findBy("id", opts.categoryId); From 2b40049abb498095beec29787955e77c5cfc89b8 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 22 Oct 2021 08:57:51 +1000 Subject: [PATCH 016/254] FIX: Remove superfluous topic allowed users on group invite (#14656) When inviting a group to a topic, there may be members of the group already in the topic as topic allowed users. These can be safely removed from the topic, because they are implicitly allowed in the topic based on their group membership. Also, this prevents issues with group SMTP emails, which rely on the topic_allowed_users of the topic to send to and cc's for emails, and if there are members of the group as topic_allowed_users then that complicates things and causes odd behaviour. We also ensure that the OP of the topic is not removed from the topic_allowed_users when a group they belong to is added, as it will make it harder to add them back later. --- .../discourse/app/components/invite-panel.js | 9 ++-- app/models/topic.rb | 28 ++++++++++-- spec/models/topic_spec.rb | 45 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/invite-panel.js b/app/assets/javascripts/discourse/app/components/invite-panel.js index dd55ebeed2..17ea67436e 100644 --- a/app/assets/javascripts/discourse/app/components/invite-panel.js +++ b/app/assets/javascripts/discourse/app/components/invite-panel.js @@ -351,12 +351,11 @@ export default Component.extend({ if (this.isInviteeGroup) { return this.inviteModel .createGroupInvite(this.invitee.trim()) - .then((data) => { + .then(() => { model.setProperties({ saving: false, finished: true }); - this.get("inviteModel.details.allowed_groups").pushObject( - EmberObject.create(data.group) - ); - this.appEvents.trigger("post-stream:refresh"); + this.inviteModel.reload().then(() => { + this.appEvents.trigger("post-stream:refresh"); + }); }) .catch(onerror); } else { diff --git a/app/models/topic.rb b/app/models/topic.rb index 5f807a7dfe..81d0d56ad8 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -971,16 +971,38 @@ class Topic < ActiveRecord::Base end def invite_group(user, group) - TopicAllowedGroup.create!(topic_id: id, group_id: group.id) - allowed_groups.reload + TopicAllowedGroup.create!(topic_id: self.id, group_id: group.id) + self.allowed_groups.reload - last_post = posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first + last_post = self.posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first if last_post Jobs.enqueue(:post_alert, post_id: last_post.id) add_small_action(user, "invited_group", group.name) Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id) end + # If the group invited includes the OP of the topic as one of is members, + # we cannot strip the topic_allowed_user record since it will be more + # complicated to recover the topic_allowed_user record for the OP if the + # group is removed. + allowed_user_where_clause = <<~SQL + users.id IN ( + SELECT topic_allowed_users.user_id + FROM topic_allowed_users + INNER JOIN group_users ON group_users.user_id = topic_allowed_users.user_id + INNER JOIN topic_allowed_groups ON topic_allowed_groups.group_id = group_users.group_id + WHERE topic_allowed_groups.group_id = :group_id AND + topic_allowed_users.topic_id = :topic_id AND + topic_allowed_users.user_id != :op_user_id + ) + SQL + User.where([ + allowed_user_where_clause, + { group_id: group.id, topic_id: self.id, op_user_id: self.user_id } + ]).find_each do |allowed_user| + remove_allowed_user(Discourse.system_user, allowed_user) + end + true end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 64d599f019..40d9612578 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1021,6 +1021,51 @@ describe Topic do .to eq(Notification.types[:group_message_summary]) end + + it "removes users in topic_allowed_users who are part of the added group" do + admins = Group[:admins] + admins.update!(messageable_level: Group::ALIAS_LEVELS[:everyone]) + + # clear up the state so we can be more explicit with the test + TopicAllowedUser.where(topic: topic).delete_all + user0 = topic.user + user1 = Fabricate(:user) + user2 = Fabricate(:user) + user3 = Fabricate(:user) + Fabricate(:topic_allowed_user, topic: topic, user: user0) + Fabricate(:topic_allowed_user, topic: topic, user: user1) + Fabricate(:topic_allowed_user, topic: topic, user: user2) + Fabricate(:topic_allowed_user, topic: topic, user: user3) + + admins.add(user1) + admins.add(user2) + + other_topic = Fabricate(:topic) + Fabricate(:topic_allowed_user, user: user1, topic: other_topic) + + expect(topic.invite_group(topic.user, admins)).to eq(true) + expect(topic.posts.last.action_code).to eq("removed_user") + expect(topic.allowed_users).to match_array([user0, user3, Discourse.system_user]) + expect(other_topic.allowed_users).to match_array([user1]) + end + + it "does not remove the OP from topic_allowed_users if they are part of an added group" do + admins = Group[:admins] + admins.update!(messageable_level: Group::ALIAS_LEVELS[:everyone]) + + # clear up the state so we can be more explicit with the test + TopicAllowedUser.where(topic: topic).delete_all + user0 = topic.user + user1 = Fabricate(:user) + Fabricate(:topic_allowed_user, topic: topic, user: user0) + Fabricate(:topic_allowed_user, topic: topic, user: user1) + + admins.add(topic.user) + admins.add(user1) + + expect(topic.invite_group(topic.user, admins)).to eq(true) + expect(topic.allowed_users).to match_array([topic.user, Discourse.system_user]) + end end end end From 7290a74aa62f759aeeb1564e34c19124c72aeadf Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 22 Oct 2021 09:38:02 +1000 Subject: [PATCH 017/254] DEV: Add new bookmarks:changed app event (#14674) This new app event will fire whenever a bookmark is created, edited, or deleted for a post or topic, and replaces these old app events which had inconsistent APIs: * page:bookmark-post-toggled * topic:bookmark-toggled When the event is triggered, the arguments are in this order: 1. bookmark - The bookmark record created or changed. Will be null if the bookmark was deleted. 2. target - Object with target (post or topic) and targetId (post ID or topic ID) --- .../discourse/app/components/bookmark-list.js | 12 +++++++++++- .../discourse/app/components/bookmark.js | 1 + .../discourse/app/controllers/bookmark.js | 1 + .../javascripts/discourse/app/controllers/topic.js | 14 +++++++++++++- .../javascripts/discourse/app/models/bookmark.js | 7 +++++++ .../javascripts/discourse/app/models/post.js | 13 +++++++++++-- .../javascripts/discourse/app/models/topic.js | 6 ++++++ lib/topic_view.rb | 2 +- 8 files changed, 51 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/bookmark-list.js b/app/assets/javascripts/discourse/app/components/bookmark-list.js index 7272ab7381..09062748c0 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark-list.js +++ b/app/assets/javascripts/discourse/app/components/bookmark-list.js @@ -19,6 +19,11 @@ export default Component.extend({ bookmark .destroy() .then(() => { + this.appEvents.trigger( + "bookmarks:changed", + null, + bookmark.attachedTo() + ); this._removeBookmarkFromList(bookmark); resolve(true); }) @@ -52,7 +57,12 @@ export default Component.extend({ @action editBookmark(bookmark) { openBookmarkModal(bookmark, { - onAfterSave: () => { + onAfterSave: (savedData) => { + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); this.reload(); }, onAfterDelete: () => { diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js index 35b8a31a4e..7f7f53ef71 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark.js +++ b/app/assets/javascripts/discourse/app/components/bookmark.js @@ -201,6 +201,7 @@ export default Component.extend({ post_id: this.model.postId, id: this.model.id || response.id, name: this.model.name, + topic_id: this.model.topicId, }); }, diff --git a/app/assets/javascripts/discourse/app/controllers/bookmark.js b/app/assets/javascripts/discourse/app/controllers/bookmark.js index b47b83b951..4b0136a31b 100644 --- a/app/assets/javascripts/discourse/app/controllers/bookmark.js +++ b/app/assets/javascripts/discourse/app/controllers/bookmark.js @@ -24,6 +24,7 @@ export function openBookmarkModal( let modalController = showModal("bookmark", { model: { postId: bookmark.post_id, + topicId: bookmark.topic_id, id: bookmark.id, reminderAt: bookmark.reminder_at, autoDeletePreference: bookmark.auto_delete_preference, diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index 5ebaa903e2..331db6d8ba 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -755,7 +755,11 @@ export default Controller.extend(bufferedProperty("model"), { (bookmark) => bookmark.post_id === post.id && !bookmark.for_topic ); return this._modifyPostBookmark( - bookmarkForPost || { post_id: post.id, for_topic: false }, + bookmarkForPost || { + post_id: post.id, + topic_id: post.topic_id, + for_topic: false, + }, post ); } else { @@ -1231,6 +1235,13 @@ export default Controller.extend(bufferedProperty("model"), { this.model.set("bookmarking", false); this.model.set("bookmarked", true); this.model.incrementProperty("bookmarksWereChanged"); + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + + // TODO (martin) (2022-02-01) Remove these old bookmark events, replaced by bookmarks:changed. this.appEvents.trigger("topic:bookmark-toggled"); }, onAfterDelete: (topicBookmarked, bookmarkId) => { @@ -1300,6 +1311,7 @@ export default Controller.extend(bufferedProperty("model"), { const firstPost = await this.model.firstPost(); return this._modifyTopicBookmark({ post_id: firstPost.id, + topic_id: this.model.id, for_topic: true, }); } diff --git a/app/assets/javascripts/discourse/app/models/bookmark.js b/app/assets/javascripts/discourse/app/models/bookmark.js index 735e7d00e0..e33f3e1dce 100644 --- a/app/assets/javascripts/discourse/app/models/bookmark.js +++ b/app/assets/javascripts/discourse/app/models/bookmark.js @@ -36,6 +36,13 @@ const Bookmark = RestModel.extend({ }); }, + attachedTo() { + if (this.for_topic) { + return { target: "topic", targetId: this.topic_id }; + } + return { target: "post", targetId: this.post_id }; + }, + togglePin() { if (this.newBookmark) { return Promise.resolve(); diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 74d94bb970..6d2a43d829 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -314,6 +314,11 @@ const Post = RestModel.extend({ bookmark_id: data.id, }); this.topic.incrementProperty("bookmarksWereChanged"); + this.appEvents.trigger("bookmarks:changed", data, { + target: "post", + targetId: this.id, + }); + // TODO (martin) (2022-02-01) Remove these old bookmark events, replaced by bookmarks:changed. this.appEvents.trigger("page:bookmark-post-toggled", this); this.appEvents.trigger("post-stream:refresh", { id: this.id }); }, @@ -321,8 +326,6 @@ const Post = RestModel.extend({ deleteBookmark(bookmarked) { this.set("topic.bookmarked", bookmarked); this.clearBookmark(); - this.topic.incrementProperty("bookmarksWereChanged"); - this.appEvents.trigger("page:bookmark-post-toggled", this); }, clearBookmark() { @@ -334,6 +337,12 @@ const Post = RestModel.extend({ bookmark_auto_delete_preference: null, }); this.topic.incrementProperty("bookmarksWereChanged"); + this.appEvents.trigger("bookmarks:changed", null, { + target: "post", + targetId: this.id, + }); + // TODO (martin) (2022-02-01) Remove these old bookmark events, replaced by bookmarks:changed. + this.appEvents.trigger("page:bookmark-post-toggled", this); }, updateActionsSummary(json) { diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index eed03afc39..e7c102cfab 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -386,7 +386,13 @@ const Topic = RestModel.extend({ "bookmarks", this.bookmarks.filter((bookmark) => { if (bookmark.id === id && bookmark.for_topic) { + // TODO (martin) (2022-02-01) Remove these old bookmark events, replaced by bookmarks:changed. this.appEvents.trigger("topic:bookmark-toggled"); + this.appEvents.trigger( + "bookmarks:changed", + null, + bookmark.attachedTo() + ); } return bookmark.id !== id; diff --git a/lib/topic_view.rb b/lib/topic_view.rb index ec21530826..1f2cb0103b 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -388,7 +388,7 @@ class TopicView def bookmarks @bookmarks ||= @topic.bookmarks.where(user: @user).joins(:topic).select( - :id, :post_id, :for_topic, :reminder_at, :name, :auto_delete_preference + :id, :post_id, "topics.id AS topic_id", :for_topic, :reminder_at, :name, :auto_delete_preference ) end From fffbb6960689f50e9b16d5ddbeef38bb15f4a8ed Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 22 Oct 2021 09:39:49 +1000 Subject: [PATCH 018/254] DEV: Remove enable_experimental_image_uploader site setting (#14691) This setting was already removed in the UI and the DB in 2364626dedc82b0ff3086407f68705cff22c2849, but I forgot to remove the actual setting from yml. --- config/site_settings.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 107c4b91d4..e78f38fd37 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -267,10 +267,6 @@ basic: client: true default: true hidden: true - enable_experimental_image_uploader: - client: true - default: false - hidden: true enable_experimental_composer_uploader: client: true default: false From fbced6cd8588fc39a6f6f6d51943ebbc92e0d60e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Oct 2021 10:21:33 +0800 Subject: [PATCH 019/254] Build(deps): Bump image_optim from 0.31.0 to 0.31.1 (#14689) Bumps [image_optim](https://github.com/toy/image_optim) from 0.31.0 to 0.31.1. - [Release notes](https://github.com/toy/image_optim/releases) - [Changelog](https://github.com/toy/image_optim/blob/master/CHANGELOG.markdown) - [Commits](https://github.com/toy/image_optim/compare/v0.31.0...v0.31.1) --- updated-dependencies: - dependency-name: image_optim dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9b0ed5b9f4..d14324de3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -173,13 +173,13 @@ GEM http_accept_language (2.1.1) i18n (1.8.10) concurrent-ruby (~> 1.0) - image_optim (0.31.0) + image_optim (0.31.1) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) - image_size (>= 1.5, < 3) + image_size (>= 1.5, < 4) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - image_size (2.1.2) + image_size (3.0.1) in_threads (1.5.4) ipaddr (1.2.2) jmespath (1.4.0) From c7703cec2f0b7f622768bbc6bff4d4d63ce9baa2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Oct 2021 10:21:50 +0800 Subject: [PATCH 020/254] Build(deps): Bump zeitwerk from 2.5.0 to 2.5.1 (#14688) Bumps [zeitwerk](https://github.com/fxn/zeitwerk) from 2.5.0 to 2.5.1. - [Release notes](https://github.com/fxn/zeitwerk/releases) - [Changelog](https://github.com/fxn/zeitwerk/blob/main/CHANGELOG.md) - [Commits](https://github.com/fxn/zeitwerk/compare/v2.5.0...v2.5.1) --- updated-dependencies: - dependency-name: zeitwerk dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d14324de3f..d5678b82de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -473,7 +473,7 @@ GEM jwt (~> 2.0) xorcist (1.1.2) yaml-lint (0.0.10) - zeitwerk (2.5.0) + zeitwerk (2.5.1) PLATFORMS arm64-darwin-20 From d1201d618832fefa290760447a8125ca5ca159ff Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 22 Oct 2021 10:22:09 +0800 Subject: [PATCH 021/254] DEV: Pass topic to `TopicView.add_post_custom_fields_allowlister` (#14678) Allows custom fields to be loaded based on the attributes of a topic. --- lib/plugin/instance.rb | 4 ++-- lib/topic_view.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index f7e9305e34..7158f0d3c1 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -326,8 +326,8 @@ class Plugin::Instance # Add a post_custom_fields_allowlister block to the TopicView, respecting if the plugin is enabled def topic_view_post_custom_fields_allowlister(&block) reloadable_patch do |plugin| - ::TopicView.add_post_custom_fields_allowlister do |user| - plugin.enabled? ? block.call(user) : [] + ::TopicView.add_post_custom_fields_allowlister do |user, topic| + plugin.enabled? ? block.call(user, topic) : [] end end end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 1f2cb0103b..7892c2db58 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -65,8 +65,8 @@ class TopicView post_custom_fields_allowlisters << block end - def self.allowed_post_custom_fields(user) - wpcf = default_post_custom_fields + post_custom_fields_allowlisters.map { |w| w.call(user) } + def self.allowed_post_custom_fields(user, topic) + wpcf = default_post_custom_fields + post_custom_fields_allowlisters.map { |w| w.call(user, topic) } wpcf.flatten.uniq end @@ -116,7 +116,7 @@ class TopicView @user_custom_fields = User.custom_fields_for_ids(@posts.pluck(:user_id), added_fields) end - if (allowed_fields = TopicView.allowed_post_custom_fields(@user)).present? + if (allowed_fields = TopicView.allowed_post_custom_fields(@user, @topic)).present? @post_custom_fields = Post.custom_fields_for_ids(@posts.pluck(:id), allowed_fields) end end From 6544e3b02a0013e571aa8d226cb3591fcdd945b3 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 22 Oct 2021 10:38:21 +0800 Subject: [PATCH 022/254] DEV: Remove useless ordering when searching within a topic. (#14676) Searching within a topic currently does not make use of PG search and we're simply doing an `ilike` against the post raw. Furthermore, `Post#post_number` is already unique within a topic so the other ordering will never ever be used. This change simply makes the query cleaner to read. --- lib/search.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/search.rb b/lib/search.rb index dd5581d886..231b5df29f 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -93,7 +93,6 @@ class Search end data = data.join(' ') - else data.squish! end @@ -990,7 +989,7 @@ class Search .push(@search_context.id) posts.where("topics.category_id in (?)", category_ids) - elsif @search_context.is_a?(Topic) + elsif is_topic_search posts.where("topics.id = #{@search_context.id}") .order("posts.post_number #{@order == :latest ? "DESC" : ""}") elsif @search_context.is_a?(Tag) @@ -1028,7 +1027,7 @@ class Search else posts = posts.order("posts.like_count DESC") end - else + elsif !is_topic_search rank = <<~SQL TS_RANK_CD( post_search_data.search_data, From 76a9ca99a88f7f78590b18adbf30c3d4f4e28c71 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 22 Oct 2021 13:15:46 +0200 Subject: [PATCH 023/254] DEV: removes jquery usage from onebox (#14683) --- .../discourse/app/lib/load-oneboxes.js | 2 +- .../discourse/tests/unit/lib/oneboxer-test.js | 6 +- .../pretty-text/addon/inline-oneboxer.js | 8 +-- .../pretty-text/addon/oneboxer-cache.js | 3 +- .../javascripts/pretty-text/addon/oneboxer.js | 64 ++++++++++--------- 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/load-oneboxes.js b/app/assets/javascripts/discourse/app/lib/load-oneboxes.js index 0be10658ec..24d599a61b 100644 --- a/app/assets/javascripts/discourse/app/lib/load-oneboxes.js +++ b/app/assets/javascripts/discourse/app/lib/load-oneboxes.js @@ -21,7 +21,7 @@ export function loadOneboxes( ).length; container - .querySelectorAll(`a.onebox, a.inline-onebox-loading`) + .querySelectorAll("a.onebox, a.inline-onebox-loading") .forEach((link) => { const isInline = link.classList.contains("inline-onebox-loading"); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/oneboxer-test.js b/app/assets/javascripts/discourse/tests/unit/lib/oneboxer-test.js index 7ac11a624a..6510a5ab38 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/oneboxer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/oneboxer-test.js @@ -40,9 +40,9 @@ module("Unit | Utility | oneboxer", function () { await loadOnebox(element); assert.ok( - localCache["http://somegoodurl.com"] - .prop("outerHTML") - .indexOf("Yet another collaboration tool") !== -1, + localCache["http://somegoodurl.com"].outerHTML.indexOf( + "Yet another collaboration tool" + ) !== -1, "stores the html of the onebox in a local cache" ); assert.ok( diff --git a/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js b/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js index 010b6a9f06..b427ce6141 100644 --- a/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js +++ b/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js @@ -24,12 +24,12 @@ export function applyInlineOneboxes(inline, ajax, opts) { result["inline-oneboxes"].forEach((onebox) => { if (onebox.title) { _cache[onebox.url] = onebox; + let links = inline[onebox.url] || []; links.forEach((link) => { - $(link) - .text(onebox.title) - .addClass("inline-onebox") - .removeClass("inline-onebox-loading"); + link.innerText = onebox.title; + link.classList.add("inline-onebox"); + link.classList.remove("inline-onebox-loading"); }); } }); diff --git a/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js b/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js index c9a98165ce..6b7daa5dee 100644 --- a/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js +++ b/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js @@ -24,6 +24,5 @@ export function normalize(url) { } export function lookupCache(url) { - const cached = localCache[normalize(url)]; - return cached && cached.prop("outerHTML"); + return localCache[normalize(url)]?.outerHTML; } diff --git a/app/assets/javascripts/pretty-text/addon/oneboxer.js b/app/assets/javascripts/pretty-text/addon/oneboxer.js index ada1bd4d4f..f5d0679765 100644 --- a/app/assets/javascripts/pretty-text/addon/oneboxer.js +++ b/app/assets/javascripts/pretty-text/addon/oneboxer.js @@ -1,6 +1,6 @@ import { failedCache, - localCache, + lookupCache, normalize, resetFailedCache, resetLocalCache, @@ -21,37 +21,38 @@ export function resetCache() { } function resolveSize(img) { - $(img).addClass("size-resolved"); + img.classList.add("size-resolved"); if (img.width > 0 && img.width === img.height) { - $(img).addClass("onebox-avatar"); + img.classList.add("onebox-avatar"); } } // Detect square images and apply smaller onebox-avatar class -function applySquareGenericOnebox($elem) { - if (!$elem.hasClass("allowlistedgeneric")) { +function applySquareGenericOnebox(elem) { + if (!elem.classList.contains("allowlistedgeneric")) { return; } - let $img = $elem.find(".onebox-body img.thumbnail"); - let img = $img[0]; + let img = elem.querySelector(".onebox-body img.thumbnail"); // already resolved... skip - if ($img.length !== 1 || $img.hasClass("size-resolved")) { + if (!img || img.classList.contains("size-resolved")) { return; } if (img.complete) { resolveSize(img); } else { - $img.on("load.onebox", () => { - resolveSize(img); - $img.off("load.onebox"); - }); + img.addEventListener("load", _handleLoadingOneboxImages); } } +function _handleLoadingOneboxImages() { + resolveSize(this); + this.removeEventListener("load", _handleLoadingOneboxImages); +} + function loadNext(ajax) { if (loadingQueue.length === 0) { timeout = null; @@ -60,7 +61,7 @@ function loadNext(ajax) { let timeoutMs = 150; let removeLoading = true; - const { url, refresh, $elem, categoryId, topicId } = loadingQueue.shift(); + const { url, refresh, elem, categoryId, topicId } = loadingQueue.shift(); // Retrieve the onebox return ajax("/onebox", { @@ -74,16 +75,19 @@ function loadNext(ajax) { }) .then( (html) => { - let $html = $(html); - setLocalCache(normalize(url), $html); - $elem.replaceWith($html); - applySquareGenericOnebox($html); + let template = document.createElement("template"); + template.innerHTML = html.trim(); + const node = template.content.firstChild; + + setLocalCache(normalize(url), node); + elem.replaceWith(node); + applySquareGenericOnebox(node); }, (result) => { - if (result && result.jqXHR && result.jqXHR.status === 429) { + if (result?.jqXHR?.status === 429) { timeoutMs = 2000; removeLoading = false; - loadingQueue.unshift({ url, refresh, $elem, categoryId, topicId }); + loadingQueue.unshift({ url, refresh, elem, categoryId, topicId }); } else { setFailedCache(normalize(url), true); } @@ -92,13 +96,13 @@ function loadNext(ajax) { .finally(() => { timeout = later(() => loadNext(ajax), timeoutMs); if (removeLoading) { - $elem.removeClass(LOADING_ONEBOX_CSS_CLASS); - $elem.data("onebox-loaded"); + elem.classList.remove(LOADING_ONEBOX_CSS_CLASS); + elem.dataset.oneboxLoaded = ""; } }); } -// Perform a lookup of a onebox based an anchor $element. +// Perform a lookup of a onebox based an anchor element. // It will insert a loading indicator and remove it when the loading is complete or fails. export function load({ elem, @@ -109,13 +113,13 @@ export function load({ offline = false, synchronous = false, }) { - const $elem = $(elem); - // If the onebox has loaded or is loading, return - if ($elem.data("onebox-loaded")) { + + if (elem.dataset.oneboxLoaded) { return; } - if ($elem.hasClass(LOADING_ONEBOX_CSS_CLASS)) { + + if (elem.classList.contains(LOADING_ONEBOX_CSS_CLASS)) { return; } @@ -124,9 +128,9 @@ export function load({ // Unless we're forcing a refresh... if (!refresh) { // If we have it in our cache, return it. - const cached = localCache[normalize(url)]; + const cached = lookupCache(url); if (cached) { - return cached.prop("outerHTML"); + return cached; } // If the request failed, don't do anything @@ -141,10 +145,10 @@ export function load({ } // Add the loading CSS class - $elem.addClass(LOADING_ONEBOX_CSS_CLASS); + elem.classList.add(LOADING_ONEBOX_CSS_CLASS); // Add to the loading queue - loadingQueue.push({ url, refresh, $elem, categoryId, topicId }); + loadingQueue.push({ url, refresh, elem, categoryId, topicId }); // Load next url in queue if (synchronous) { From 00c6d16dcec314459d605b3d672ce1f469d3048e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 22 Oct 2021 16:24:55 +0100 Subject: [PATCH 024/254] DEV: Make PresenceChannel timeout configurable per-channel (#14697) --- lib/presence_channel.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/presence_channel.rb b/lib/presence_channel.rb index a84853465b..f2e8031c4c 100644 --- a/lib/presence_channel.rb +++ b/lib/presence_channel.rb @@ -36,19 +36,21 @@ class PresenceChannel # count_only: boolean. If true, user identities are never revealed to clients. (default []) class Config NOT_FOUND ||= "notfound" - attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only - def initialize(public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false) + attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout + + def initialize(public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false, timeout: nil) @public = public @allowed_user_ids = allowed_user_ids @allowed_group_ids = allowed_group_ids @count_only = count_only + @timeout = timeout end def self.from_json(json) data = JSON.parse(json, symbolize_names: true) data = {} if !data.is_a? Hash - new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only)) + new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout)) end def to_json @@ -56,6 +58,7 @@ class PresenceChannel data[:allowed_user_ids] = allowed_user_ids if allowed_user_ids data[:allowed_group_ids] = allowed_group_ids if allowed_group_ids data[:count_only] = count_only if count_only + data[:timeout] = timeout if timeout data.to_json end end @@ -72,7 +75,6 @@ class PresenceChannel def initialize(name, raise_not_found: true, use_cache: true) @name = name - @timeout = DEFAULT_TIMEOUT @message_bus_channel_name = "/presence#{name}" begin @@ -81,6 +83,8 @@ class PresenceChannel raise if raise_not_found @config = Config.new end + + @timeout = config.timeout || DEFAULT_TIMEOUT end # Is this user allowed to view this channel? From 689a3711c00cef0e694be25b2666fd83c740cd03 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 22 Oct 2021 12:03:58 -0400 Subject: [PATCH 025/254] DEV: Remove a few unused icons (#14696) --- lib/svg_sprite/svg_sprite.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index f4b1294ea5..0b1c3b757e 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -132,7 +132,6 @@ module SvgSprite "globe-americas", "hand-point-right", "hands-helping", - "heading", "heart", "history", "home", @@ -176,7 +175,6 @@ module SvgSprite "search", "share", "shield-alt", - "shower", "sign-in-alt", "sign-out-alt", "signal", @@ -184,7 +182,6 @@ module SvgSprite "star", "step-backward", "step-forward", - "stopwatch", "stream", "sync-alt", "sync", @@ -201,7 +198,6 @@ module SvgSprite "toggle-off", "toggle-on", "trash-alt", - "tv", "undo", "unlink", "unlock", From 8c17f5b72c9cb37194fa108efff01f910869b661 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Fri, 22 Oct 2021 07:02:56 -1000 Subject: [PATCH 026/254] FEATURE: include user custom fields in base exporter (#14690) Add user custom fields in base exporter, allows Discourse->Discourse exports to transfer user custom fields --- lib/import_export/base_exporter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/import_export/base_exporter.rb b/lib/import_export/base_exporter.rb index 509266652c..5a7f4a6195 100644 --- a/lib/import_export/base_exporter.rb +++ b/lib/import_export/base_exporter.rb @@ -14,7 +14,7 @@ module ImportExport :public_admission, :membership_request_template, :messageable_level, :mentionable_level, :members_visibility_level, :publish_read_state] - USER_ATTRS = [:id, :email, :username, :name, :created_at, :trust_level, :active, :last_emailed_at] + USER_ATTRS = [:id, :email, :username, :name, :created_at, :trust_level, :active, :last_emailed_at, :custom_fields] TOPIC_ATTRS = [:id, :title, :created_at, :views, :category_id, :closed, :archived, :archetype] From 2461ed303c21b8782cc66e548dbcbb7c2c65c9c1 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 25 Oct 2021 13:17:43 +1000 Subject: [PATCH 027/254] DEV: Move loadLibs to explicit install message in media-optimization-worker (#14707) Previously, `loadLibs` was called inside the `optimize` function of the media-optimization-worker, which meant that it could be hit multiple times causing load errors (as seen in b69c2f7311836b8e35719a4d24cb60020fd7234d) This commit moves that call to a specific message handler (the `install` message) for the service worker, and refactors the service for the media-optimization-worker to wait for this installation to complete before continuing with processing image optimizations. This way, we know for sure based on promises and worker messages that the worker is installed and has all required libraries loaded before we continue on with attempting any processing. The change made in b69c2f7311836b8e35719a4d24cb60020fd7234d is no longer needed with this commit. --- .../app/services/media-optimization-worker.js | 123 ++++++++++++------ .../javascripts/media-optimization-worker.js | 47 ++++--- 2 files changed, 103 insertions(+), 67 deletions(-) diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js index 37f238776e..4feec51f37 100644 --- a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js +++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js @@ -4,6 +4,22 @@ import { Promise } from "rsvp"; import { fileToImageData } from "discourse/lib/media-optimization-utils"; import { getAbsoluteURL, getURLWithCDN } from "discourse-common/lib/get-url"; +/** + * This worker follows a particular promise/callback flow to ensure + * that the media-optimization-worker is installed and has its libraries + * loaded before optimizations can happen. The flow: + * + * 1. optimizeImage called + * 2. worker initialized and started + * 3. message handlers for worker registered + * 4. "install" message posted to worker + * 5. "installed" message received from worker + * 6. optimizeImage continues, posting "compress" message to worker + * + * When the worker is being installed, all other calls to optimizeImage + * will wait for the "installed" message to be handled before continuing + * with any image optimization work. + */ export default class MediaOptimizationWorkerService extends Service { appEvents = getOwner(this).lookup("service:app-events"); worker = null; @@ -11,35 +27,7 @@ export default class MediaOptimizationWorkerService extends Service { currentComposerUploadData = null; promiseResolvers = null; - startWorker() { - this.logIfDebug("Starting media-optimization-worker"); - this.worker = new Worker(this.workerUrl); // TODO come up with a workaround for FF that lacks type: module support - } - - stopWorker() { - if (this.worker) { - this.logIfDebug("Stopping media-optimization-worker..."); - this.worker.terminate(); - this.worker = null; - } - } - - ensureAvailiableWorker() { - if (!this.worker) { - this.startWorker(); - this.registerMessageHandler(); - this.appEvents.on("composer:closed", this, "stopWorker"); - } - } - - logIfDebug(message) { - if (this.siteSettings.composer_media_optimization_debug_mode) { - // eslint-disable-next-line no-console - console.log(message); - } - } - - optimizeImage(data, opts = {}) { + async optimizeImage(data, opts = {}) { this.usingUppy = data.id && data.id.includes("uppy"); this.promiseResolvers = this.promiseResolvers || {}; this.stopWorkerOnError = opts.hasOwnProperty("stopWorkerOnError") @@ -57,7 +45,8 @@ export default class MediaOptimizationWorkerService extends Service { ) { return this.usingUppy ? Promise.resolve() : data; } - this.ensureAvailiableWorker(); + await this.ensureAvailiableWorker(); + return new Promise(async (resolve) => { this.logIfDebug(`Transforming ${file.name}`); @@ -85,18 +74,6 @@ export default class MediaOptimizationWorkerService extends Service { width: imageData.width, height: imageData.height, settings: { - mozjpeg_script: getURLWithCDN( - "/javascripts/squoosh/mozjpeg_enc.js" - ), - mozjpeg_wasm: getURLWithCDN( - "/javascripts/squoosh/mozjpeg_enc.wasm" - ), - resize_script: getURLWithCDN( - "/javascripts/squoosh/squoosh_resize.js" - ), - resize_wasm: getURLWithCDN( - "/javascripts/squoosh/squoosh_resize_bg.wasm" - ), resize_threshold: this.siteSettings .composer_media_optimization_image_resize_dimensions_threshold, resize_target: this.siteSettings @@ -116,6 +93,54 @@ export default class MediaOptimizationWorkerService extends Service { }); } + async ensureAvailiableWorker() { + if (this.worker && this.workerInstalled) { + return Promise.resolve(); + } + if (this.installPromise) { + return this.installPromise; + } + return this.install(); + } + + async install() { + this.installPromise = new Promise((resolve) => { + this.afterInstalled = resolve; + this.logIfDebug("Installing worker."); + this.startWorker(); + this.registerMessageHandler(); + this.worker.postMessage({ + type: "install", + settings: { + mozjpeg_script: getURLWithCDN("/javascripts/squoosh/mozjpeg_enc.js"), + mozjpeg_wasm: getURLWithCDN("/javascripts/squoosh/mozjpeg_enc.wasm"), + resize_script: getURLWithCDN( + "/javascripts/squoosh/squoosh_resize.js" + ), + resize_wasm: getURLWithCDN( + "/javascripts/squoosh/squoosh_resize_bg.wasm" + ), + }, + }); + this.appEvents.on("composer:closed", this, "stopWorker"); + }); + return this.installPromise; + } + + startWorker() { + this.logIfDebug("Starting media-optimization-worker"); + this.worker = new Worker(this.workerUrl); // TODO come up with a workaround for FF that lacks type: module support + } + + stopWorker() { + if (this.worker) { + this.logIfDebug("Stopping media-optimization-worker..."); + this.workerInstalled = false; + this.worker.terminate(); + this.worker = null; + } + } + registerMessageHandler() { this.worker.onmessage = (e) => { switch (e.data.type) { @@ -153,9 +178,23 @@ export default class MediaOptimizationWorkerService extends Service { ); } break; + case "installed": + this.logIfDebug("Worker installed."); + this.workerInstalled = true; + this.afterInstalled(); + this.afterInstalled = null; + this.installPromise = null; + break; default: this.logIfDebug(`Sorry, we are out of ${e}.`); } }; } + + logIfDebug(message) { + if (this.siteSettings.composer_media_optimization_debug_mode) { + // eslint-disable-next-line no-console + console.log(message); + } + } } diff --git a/public/javascripts/media-optimization-worker.js b/public/javascripts/media-optimization-worker.js index e57e362723..b3fd8f0127 100644 --- a/public/javascripts/media-optimization-worker.js +++ b/public/javascripts/media-optimization-worker.js @@ -2,10 +2,10 @@ function resizeWithAspect( input_width, input_height, target_width, - target_height, + target_height ) { if (!target_width && !target_height) { - throw Error('Need to specify at least width or height when resizing'); + throw Error("Need to specify at least width or height when resizing"); } if (target_width && target_height) { @@ -33,9 +33,6 @@ function logIfDebug(message) { } async function optimize(imageData, fileName, width, height, settings) { - - await loadLibs(settings); - const mozJpegDefaultOptions = { quality: settings.encode_quality, baseline: false, @@ -63,7 +60,11 @@ async function optimize(imageData, fileName, width, height, settings) { // resize if (width > settings.resize_threshold) { try { - const target_dimensions = resizeWithAspect(width, height, settings.resize_target); + const target_dimensions = resizeWithAspect( + width, + height, + settings.resize_target + ); const resizeResult = self.codecs.resize( new Uint8ClampedArray(imageData), width, //in @@ -75,12 +76,12 @@ async function optimize(imageData, fileName, width, height, settings) { settings.resize_linear_rgb ); if (resizeResult[3] !== 255) { - throw "Image corrupted during resize. Falling back to the original for encode" + throw "Image corrupted during resize. Falling back to the original for encode"; } maybeResized = new ImageData( resizeResult, target_dimensions.width, - target_dimensions.height, + target_dimensions.height ).data; width = target_dimensions.width; height = target_dimensions.height; @@ -102,12 +103,12 @@ async function optimize(imageData, fileName, width, height, settings) { mozJpegDefaultOptions ); - const finalSize = result.byteLength + const finalSize = result.byteLength; logIfDebug(`Worker post reencode file: ${finalSize}`); logIfDebug(`Reduction: ${(initialSize / finalSize).toFixed(1)}x speedup`); if (finalSize < 20000) { - throw "Final size suspciously small, discarding optimizations" + throw "Final size suspciously small, discarding optimizations"; } let transferrable = Uint8Array.from(result).buffer; // decoded was allocated inside WASM so it **cannot** be transfered to another context, need to copy by value @@ -132,7 +133,7 @@ onmessage = async function (e) { type: "file", file: optimized, fileName: e.data.fileName, - fileId: e.data.fileId + fileId: e.data.fileId, }, [optimized] ); @@ -142,31 +143,27 @@ onmessage = async function (e) { type: "error", file: e.data.file, fileName: e.data.fileName, - fileId: e.data.fileId + fileId: e.data.fileId, }); } break; + case "install": + await loadLibs(e.data.settings); + postMessage({ type: "installed" }); + break; default: logIfDebug(`Sorry, we are out of ${e}.`); } }; -async function loadLibs(settings){ - +async function loadLibs(settings) { if (self.codecs) return; - if (!self.loadedMozJpeg) { - importScripts(settings.mozjpeg_script); - self.loadedMozJpeg = true; - } - - if (!self.loadedResizeScript) { - importScripts(settings.resize_script); - self.loadedResizeScript = true; - } + importScripts(settings.mozjpeg_script); + importScripts(settings.resize_script); let encoderModuleOverrides = { - locateFile: function(path, prefix) { + locateFile: function (path, prefix) { // if it's a mem init file, use a custom dir if (path.endsWith(".wasm")) return settings.mozjpeg_wasm; // otherwise, use the default, the prefix (JS file's dir) + the path @@ -181,5 +178,5 @@ async function loadLibs(settings){ const { resize } = wasm_bindgen; await wasm_bindgen(settings.resize_wasm); - self.codecs = {mozjpeg_enc: mozjpeg_enc_module, resize: resize}; + self.codecs = { mozjpeg_enc: mozjpeg_enc_module, resize: resize }; } From 261edcebcb301088522874ad656b0d0bb8f24e54 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Mon, 25 Oct 2021 10:53:42 +0300 Subject: [PATCH 028/254] FIX: Allow every tag for watched words (#14684) If a watched word was restricted to a category, new rules for that watched word could not be created. --- .../admin/addon/templates/components/watched-word-form.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index ff098b6038..a76822c955 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -18,6 +18,7 @@ class="watched-word-input-field" tags=selectedTags onChange=(action "changeSelectedTags") + everyTag=true options=(hash allowAny=true disabled=formSubmitted From b18c01e3c6560351e5f509ea7a28f1c7452a7156 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 10:24:21 +0200 Subject: [PATCH 029/254] DEV: prevents flakky spec when deleting plugin (#14701) Not reseting the registry could lead to assets still being registered for example. This flakky spec was reprdocible with this call: `bundle exec rspec --seed 9472 spec/components/discourse_plugin_registry_spec.rb spec/components/svg_sprite/svg_sprite_spec.rb` Which would trigger the following error: ``` Failures: 1) DiscoursePluginRegistry#register_asset registers vendored_core_pretty_text properly Failure/Error: expect(registry.javascripts.count).to eq(0) expected: 0 got: 1 (compared using ==) # ./spec/components/discourse_plugin_registry_spec.rb:248:in `block (3 levels) in ' # ./spec/rails_helper.rb:280:in `block (2 levels) in ' # /Users/joffreyjaffeux/.gem/ruby/2.7.3/gems/webmock-3.14.0/lib/webmock/rspec.rb:37:in `block (2 levels) in ' ``` --- spec/components/discourse_spec.rb | 1 + spec/components/svg_sprite/svg_sprite_spec.rb | 1 + spec/lib/content_security_policy_spec.rb | 2 ++ 3 files changed, 4 insertions(+) diff --git a/spec/components/discourse_spec.rb b/spec/components/discourse_spec.rb index 701cc39950..aa2542e04b 100644 --- a/spec/components/discourse_spec.rb +++ b/spec/components/discourse_spec.rb @@ -104,6 +104,7 @@ describe Discourse do after do Discourse.plugins.delete plugin1 Discourse.plugins.delete plugin2 + DiscoursePluginRegistry.reset! end before do diff --git a/spec/components/svg_sprite/svg_sprite_spec.rb b/spec/components/svg_sprite/svg_sprite_spec.rb index 15bccf6b1b..e3d1ccd3bf 100644 --- a/spec/components/svg_sprite/svg_sprite_spec.rb +++ b/spec/components/svg_sprite/svg_sprite_spec.rb @@ -243,6 +243,7 @@ describe SvgSprite do after do Discourse.plugins.delete plugin1 + DiscoursePluginRegistry.reset! end it "includes custom icons from plugins" do diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb index a2d85c538c..c8fbcb45c7 100644 --- a/spec/lib/content_security_policy_spec.rb +++ b/spec/lib/content_security_policy_spec.rb @@ -234,6 +234,7 @@ describe ContentSecurityPolicy do expect(parse(policy)['manifest-src']).to_not include('https://manifest-src.com') Discourse.plugins.delete plugin + DiscoursePluginRegistry.reset! end it 'can extend frame_ancestors' do @@ -251,6 +252,7 @@ describe ContentSecurityPolicy do expect(parse(policy)['frame-ancestors']).to_not include('https://frame-ancestors-plugin.ext') Discourse.plugins.delete plugin + DiscoursePluginRegistry.reset! end end From 05dda755edeb789f198a5e82839d7dc7c0a04612 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 10:24:37 +0200 Subject: [PATCH 030/254] DEV: removes jquery usage from linkSeenMentions codepath (#14695) --- .../app/components/composer-editor.js | 4 +- .../discourse/app/components/d-editor.js | 2 +- .../discourse/app/lib/link-mentions.js | 39 ++++++++++++------- .../tests/unit/lib/link-mentions-test.js | 32 +++++++++------ 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index b6c3d3456a..1e01a57b27 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -503,7 +503,7 @@ export default Component.extend(ComposerUpload, { // 'Create a New Topic' scenario is not supported (per conversation with codinghorror) // https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7 fetchUnseenMentions(unseen, this.get("composer.topic.id")).then(() => { - linkSeenMentions($preview, this.siteSettings); + linkSeenMentions($preview[0], this.siteSettings); this._warnMentionedGroups($preview); this._warnCannotSeeMention($preview); }); @@ -734,7 +734,7 @@ export default Component.extend(ComposerUpload, { previewUpdated($preview) { // Paint mentions - const unseenMentions = linkSeenMentions($preview, this.siteSettings); + const unseenMentions = linkSeenMentions($preview[0], this.siteSettings); if (unseenMentions.length) { discourseDebounce( this, diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index a7a879d5b4..312c0719a1 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -395,7 +395,7 @@ export default Component.extend(TextareaTextManipulation, { cookedElement.innerHTML = cooked; linkSeenHashtags($(cookedElement)); - linkSeenMentions($(cookedElement), this.siteSettings); + linkSeenMentions(cookedElement, this.siteSettings); resolveCachedShortUrls(this.siteSettings, cookedElement); loadOneboxes( cookedElement, diff --git a/app/assets/javascripts/discourse/app/lib/link-mentions.js b/app/assets/javascripts/discourse/app/lib/link-mentions.js index e7cd5c29de..ab9aca4811 100644 --- a/app/assets/javascripts/discourse/app/lib/link-mentions.js +++ b/app/assets/javascripts/discourse/app/lib/link-mentions.js @@ -1,3 +1,4 @@ +import deprecated from "discourse-common/lib/deprecated"; import { ajax } from "discourse/lib/ajax"; import { formatUsername } from "discourse/lib/utilities"; import getURL from "discourse-common/lib/get-url"; @@ -5,10 +6,9 @@ import { userPath } from "discourse/lib/url"; let maxGroupMention; -function replaceSpan($e, username, opts) { +function replaceSpan(element, username, opts) { let extra = {}; let extraClass = []; - const element = $e[0]; const a = document.createElement("a"); if (opts && opts.group) { @@ -48,30 +48,39 @@ const mentionableGroups = {}; const checked = {}; const cannotSee = []; -function updateFound($mentions, usernames) { - $mentions.each((i, e) => { - const $e = $(e); - const username = usernames[i]; +function updateFound(mentions, usernames) { + mentions.forEach((mention, index) => { + const username = usernames[index]; if (found[username.toLowerCase()]) { - replaceSpan($e, username, { cannot_see: cannotSee[username] }); + replaceSpan(mention, username, { cannot_see: cannotSee[username] }); } else if (mentionableGroups[username]) { - replaceSpan($e, username, { + replaceSpan(mention, username, { group: true, mentionable: mentionableGroups[username], }); } else if (foundGroups[username]) { - replaceSpan($e, username, { group: true }); + replaceSpan(mention, username, { group: true }); } else if (checked[username]) { - $e.addClass("mention-tested"); + mention.classList.add("mention-tested"); } }); } -export function linkSeenMentions($elem, siteSettings) { - const $mentions = $("span.mention:not(.mention-tested)", $elem); - if ($mentions.length) { - const usernames = [...$mentions.map((_, e) => $(e).text().substr(1))]; - updateFound($mentions, usernames); +export function linkSeenMentions(elem, siteSettings) { + if (elem instanceof jQuery) { + elem = elem[0]; + + deprecated("linkSeenMentions now expects a DOM node as first parameter", { + since: "2.8.0.beta7", + }); + } + + const mentions = [ + ...elem.querySelectorAll("span.mention:not(.mention-tested)"), + ]; + if (mentions.length) { + const usernames = mentions.map((m) => m.innerText.substr(1)); + updateFound(mentions, usernames); return usernames .uniq() .filter( diff --git a/app/assets/javascripts/discourse/tests/unit/lib/link-mentions-test.js b/app/assets/javascripts/discourse/tests/unit/lib/link-mentions-test.js index 53c072bdc9..c18887b6e6 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/link-mentions-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/link-mentions-test.js @@ -32,31 +32,39 @@ module("Unit | Utility | link-mentions", function () { "invalid", ]); - let $root = $(` + let html = `
- @invalid - @valid_user - @valid_group - @mentionable_group + @invalid + @valid_user + @valid_group + @mentionable_group
- `); + `; - await linkSeenMentions($root); + let template = document.createElement("template"); + html = html.trim(); + template.innerHTML = html; + const root = template.content.firstChild; + + await linkSeenMentions(root); // Ember.Test.registerWaiter is not available here, so we are implementing // our own await new Promise((resolve) => { const interval = setInterval(() => { - if ($("a", $root).length > 0) { + if (root.querySelectorAll("a").length > 0) { clearInterval(interval); resolve(); } }, 500); }); - assert.equal($("a", $root)[0].text, "@valid_user"); - assert.equal($("a", $root)[1].text, "@valid_group"); - assert.equal($("a.notify", $root).text(), "@mentionable_group"); - assert.equal($("span.mention", $root)[0].innerHTML, "@invalid"); + assert.equal(root.querySelector("a").innerText, "@valid_user"); + assert.equal(root.querySelectorAll("a")[1].innerText, "@valid_group"); + assert.equal( + root.querySelector("a.notify").innerText, + "@mentionable_group" + ); + assert.equal(root.querySelector("span.mention").innerHTML, "@invalid"); }); }); From cc68eb825b87c27cf09964699a5c64f93b4274a9 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 10:24:53 +0200 Subject: [PATCH 031/254] DEV: drops jquery usage from ajax wizards lib (#14694) --- app/assets/javascripts/wizard/lib/ajax.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/wizard/lib/ajax.js b/app/assets/javascripts/wizard/lib/ajax.js index 670747e807..3889069f71 100644 --- a/app/assets/javascripts/wizard/lib/ajax.js +++ b/app/assets/javascripts/wizard/lib/ajax.js @@ -7,7 +7,7 @@ let token; export function getToken() { if (!token) { - token = $('meta[name="csrf-token"]').attr("content"); + token = document.querySelector('meta[name="csrf-token"]')?.content; } return token; From c0300b13e3d36a5d7be2a73f7d47fc6658eaa9d5 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 10:49:14 +0200 Subject: [PATCH 032/254] DEV: replaces jquery call by vanilla js in uploader tests (#14702) --- .../tests/integration/components/image-uploader-test.js | 2 +- .../tests/integration/components/uppy-image-uploader-test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js b/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js index 02c06a5054..b92a9abc22 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js @@ -38,7 +38,7 @@ discourseModule("Integration | Component | image-uploader", function (hooks) { await click(".image-uploader-lightbox-btn"); assert.equal( - $(".mfp-container").length, + document.querySelectorAll(".mfp-container").length, 1, "it displays the image lightbox" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/uppy-image-uploader-test.js b/app/assets/javascripts/discourse/tests/integration/components/uppy-image-uploader-test.js index d43c751163..1a81bb1445 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/uppy-image-uploader-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/uppy-image-uploader-test.js @@ -40,7 +40,7 @@ discourseModule( await click(".image-uploader-lightbox-btn"); assert.equal( - $(".mfp-container").length, + document.querySelectorAll(".mfp-container").length, 1, "it displays the image lightbox" ); From c7099342ff6c9fce1f11df81b50605c414df4eb3 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 10:59:58 +0200 Subject: [PATCH 033/254] DEV: uses vanilla js for fixture qunit helper (#14703) --- .../discourse/tests/helpers/qunit-helpers.js | 4 +- .../unit/lib/click-track-edit-history-test.js | 8 +- .../unit/lib/click-track-profile-page-test.js | 8 +- .../tests/unit/lib/click-track-test.js | 26 +++---- .../tests/unit/lib/highlight-search-test.js | 28 ++----- .../tests/unit/lib/upload-short-url-test.js | 73 +++++++++---------- 6 files changed, 64 insertions(+), 83 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index e5e71d4ac2..d2c64d0082 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -385,9 +385,9 @@ export function controllerFor(controller, model) { export function fixture(selector) { if (selector) { - return $("#qunit-fixture").find(selector); + return document.querySelector(`#qunit-fixture ${selector}`); } - return $("#qunit-fixture"); + return document.querySelector("#qunit-fixture"); } QUnit.assert.not = function (actual, message) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/click-track-edit-history-test.js b/app/assets/javascripts/discourse/tests/unit/lib/click-track-edit-history-test.js index f6e875e6a6..c5eea3a33a 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/click-track-edit-history-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/click-track-edit-history-test.js @@ -9,7 +9,7 @@ import sinon from "sinon"; const track = ClickTrack.trackClick; function generateClickEventOn(selector) { - return $.Event("click", { currentTarget: fixture(selector).first() }); + return $.Event("click", { currentTarget: fixture(selector) }); } module("Unit | Utility | click-track-edit-history", function (hooks) { @@ -25,8 +25,7 @@ module("Unit | Utility | click-track-edit-history", function (hooks) { sessionStorage.clear(); - fixture().html( - `
+ fixture().innerHTML = `
@@ -55,8 +54,7 @@ module("Unit | Utility | click-track-edit-history", function (hooks) { #hashtag
-
` - ); + `; }); skip("tracks internal URLs", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/click-track-profile-page-test.js b/app/assets/javascripts/discourse/tests/unit/lib/click-track-profile-page-test.js index 1de09ca57a..9d74ee8f6f 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/click-track-profile-page-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/click-track-profile-page-test.js @@ -8,7 +8,7 @@ import sinon from "sinon"; const track = ClickTrack.trackClick; function generateClickEventOn(selector) { - return $.Event("click", { currentTarget: fixture(selector).first() }); + return $.Event("click", { currentTarget: fixture(selector) }); } module("Unit | Utility | click-track-profile-page", function (hooks) { @@ -24,8 +24,7 @@ module("Unit | Utility | click-track-profile-page", function (hooks) { sessionStorage.clear(); - fixture().html( - `

+ fixture().innerHTML = `

google.com google.com

@@ -48,8 +47,7 @@ module("Unit | Utility | click-track-profile-page", function (hooks) { forum log.txt #hashtag -

` - ); +

`; }); skip("tracks internal URLs", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js index 43cf438d81..d7bed4aebf 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js @@ -11,7 +11,7 @@ import { setPrefix } from "discourse-common/lib/get-url"; const track = ClickTrack.trackClick; function generateClickEventOn(selector) { - return $.Event("click", { currentTarget: fixture(selector).first() }); + return $.Event("click", { currentTarget: fixture(selector) }); } module("Unit | Utility | click-track", function (hooks) { @@ -27,8 +27,7 @@ module("Unit | Utility | click-track", function (hooks) { sessionStorage.clear(); - fixture().html( - `
+ fixture().innerHTML = `
-
` - ); +
`; }); skip("tracks internal URLs", async function (assert) { @@ -189,11 +187,11 @@ module("Unit | Utility | click-track", function (hooks) { assert.notOk(track(generateClickEventOn("a"))); - let $link = fixture("a").first(); - assert.ok($link.hasClass("no-href")); - assert.equal($link.data("href"), "http://www.google.com/"); - assert.blank($link.attr("href")); - assert.ok($link.data("auto-route")); + let link = fixture("a"); + assert.ok(link.classList.contains("no-href")); + assert.equal(link.dataset.href, "http://www.google.com/"); + assert.blank(link.getAttribute("href")); + assert.ok(link.dataset.autoRoute); assert.ok(window.open.calledWith("http://www.google.com/", "_blank")); }); @@ -206,15 +204,15 @@ module("Unit | Utility | click-track", function (hooks) { const done = assert.async(); later(() => { - assert.equal(fixture("a").attr("href"), "http://www.google.com"); + assert.equal(fixture("a").getAttribute("href"), "http://www.google.com"); done(); }); }); function badgeClickCount(assert, id, expected) { - track(generateClickEventOn("#" + id)); - let $badge = $("span.badge", fixture("#" + id).first()); - assert.equal(parseInt($badge.html(), 10), expected); + track(generateClickEventOn(`#${id}`)); + const badge = fixture(`#${id}`).querySelector("span.badge"); + assert.equal(parseInt(badge.innerHTML, 10), expected); } test("does not update badge clicks on my own link", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/highlight-search-test.js b/app/assets/javascripts/discourse/tests/unit/lib/highlight-search-test.js index 165c53b5de..e069ec83f3 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/highlight-search-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/highlight-search-test.js @@ -4,19 +4,13 @@ import { fixture } from "discourse/tests/helpers/qunit-helpers"; module("Unit | Utility | highlight-search", function () { test("highlighting text", function (assert) { - fixture().html( - ` + fixture().innerHTML = `

This is some text to highlight

- ` - ); + `; - highlightSearch(fixture()[0], "some text"); + highlightSearch(fixture(), "some text"); - const terms = []; - - fixture(`.${CLASS_NAME}`).each((_, elem) => { - terms.push(elem.textContent); - }); + const terms = [fixture(`.${CLASS_NAME}`).textContent]; assert.equal( terms.join(" "), @@ -26,19 +20,13 @@ module("Unit | Utility | highlight-search", function () { }); test("highlighting unicode text", function (assert) { - fixture().html( - ` + fixture().innerHTML = `

This is some தமிழ் & русский text to highlight

- ` - ); + `; - highlightSearch(fixture()[0], "தமிழ் & русский"); + highlightSearch(fixture(), "தமிழ் & русский"); - const terms = []; - - fixture(`.${CLASS_NAME}`).each((_, elem) => { - terms.push(elem.textContent); - }); + const terms = [fixture(`.${CLASS_NAME}`).textContent]; assert.equal( terms.join(" "), diff --git a/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js b/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js index 5d1cf7d824..35b1ac06a6 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js @@ -64,25 +64,24 @@ function stubUrls(imageSrcs, attachmentSrcs, otherMediaSrcs) { response(imageSrcs.concat(attachmentSrcs.concat(otherMediaSrcs))) ); - fixture().html( + fixture().innerHTML = imageSrcs.map((src) => ``).join("") + - attachmentSrcs - .map( - (src) => - `big enterprise contract.pdf` - ) - .join("") + - `
` + - otherMediaSrcs - .map((src) => { - if (src.short_url.indexOf("mp3") > -1) { - return ``; - } else { - return ``; - } - }) - .join("") - ); + attachmentSrcs + .map( + (src) => + `big enterprise contract.pdf` + ) + .join("") + + `
` + + otherMediaSrcs + .map((src) => { + if (src.short_url.indexOf("mp3") > -1) { + return ``; + } else { + return ``; + } + }) + .join(""); } module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { @@ -97,7 +96,7 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { lookup = lookupCachedUploadUrl("upload://a.jpeg"); assert.deepEqual(lookup, {}); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); lookup = lookupCachedUploadUrl("upload://a.jpeg"); @@ -144,36 +143,36 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { test("resolveAllShortUrls - href + src replaced correctly", async function (assert) { stubUrls(); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); - let image1 = fixture().find("img").eq(0); - let image2 = fixture().find("img").eq(1); - let link = fixture().find("a"); - let audio = fixture().find("audio").eq(0); - let video = fixture().find("video").eq(0); + let image1 = fixture().querySelector("img"); + let image2 = fixture().querySelectorAll("img")[1]; + let audio = fixture().querySelector("audio"); + let video = fixture().querySelector("video"); + let link = fixture().querySelector("a"); - assert.equal(image1.attr("src"), "/images/avatar.png?a"); - assert.equal(image2.attr("src"), "/images/avatar.png?b"); - assert.equal(link.attr("href"), "/uploads/short-url/c.pdf"); + assert.equal(image1.getAttribute("src"), "/images/avatar.png?a"); + assert.equal(image2.getAttribute("src"), "/images/avatar.png?b"); + assert.equal(link.getAttribute("href"), "/uploads/short-url/c.pdf"); assert.equal( - video.find("source").attr("src"), + video.querySelector("source").getAttribute("src"), "/uploads/default/original/3X/c/b/4.mp4" ); assert.equal( - audio.find("source").attr("src"), + audio.querySelector("source").getAttribute("src"), "/uploads/default/original/3X/c/b/5.mp3" ); }); test("resolveAllShortUrls - url with full origin replaced correctly", async function (assert) { stubUrls(); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); - let video = fixture().find("video").eq(1); + let video = fixture().querySelectorAll("video")[1]; assert.equal( - video.find("source").attr("src"), + video.querySelector("source").getAttribute("src"), "http://localhost:3000/uploads/default/original/3X/c/b/6.mp4" ); }); @@ -190,12 +189,12 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { ], null ); - await resolveAllShortUrls(ajax, { secure_media: true }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: true }, fixture()); await settled(); - let link = fixture().find("a"); + let link = fixture().querySelector("a"); assert.equal( - link.attr("href"), + link.getAttribute("href"), "/secure-media-uploads/default/original/3X/c/b/3.pdf" ); }); @@ -204,7 +203,7 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { stubUrls(); let lookup; - let scopedElement = fixture()[0].querySelector(".scoped-area"); + let scopedElement = fixture().querySelector(".scoped-area"); await resolveAllShortUrls(ajax, {}, scopedElement); await settled(); From cbd7898d1d70198f9fda0eac27a68affde5c8bb4 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 25 Oct 2021 11:00:15 +0200 Subject: [PATCH 034/254] DEV: removes jquery usage from time formatter (#14700) --- .../app/initializers/relative-ages.js | 2 +- .../discourse/app/lib/formatter.js | 28 +++++--- .../tests/unit/lib/formatter-test.js | 69 +++++++++++-------- 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/discourse/app/initializers/relative-ages.js b/app/assets/javascripts/discourse/app/initializers/relative-ages.js index 3e76e39f25..887725954a 100644 --- a/app/assets/javascripts/discourse/app/initializers/relative-ages.js +++ b/app/assets/javascripts/discourse/app/initializers/relative-ages.js @@ -6,7 +6,7 @@ export default { initialize() { this._interval = setInterval(function () { - updateRelativeAge($(".relative-date")); + updateRelativeAge(document.querySelectorAll(".relative-date")); }, 60 * 1000); }, diff --git a/app/assets/javascripts/discourse/app/lib/formatter.js b/app/assets/javascripts/discourse/app/lib/formatter.js index 22b1b7f5e5..f2ad73796d 100644 --- a/app/assets/javascripts/discourse/app/lib/formatter.js +++ b/app/assets/javascripts/discourse/app/lib/formatter.js @@ -1,5 +1,6 @@ +import { helperContext, makeArray } from "discourse-common/lib/helpers"; +import deprecated from "discourse-common/lib/deprecated"; import I18n from "I18n"; -import { helperContext } from "discourse-common/lib/helpers"; export function shortDate(date) { return moment(date).format(I18n.t("dates.medium.date_year")); @@ -49,15 +50,22 @@ export function longDateNoYear(dt) { } export function updateRelativeAge(elems) { - // jQuery .each - elems.each(function () { - const $this = $(this); - $this.html( - relativeAge(new Date($this.data("time")), { - format: $this.data("format"), - wrapInSpan: false, - }) - ); + if (elems instanceof jQuery) { + elems = elems.toArray(); + deprecated("updateRelativeAge now expects a DOM NodeList", { + since: "2.8.0.beta7", + }); + } + + if (!NodeList.prototype.isPrototypeOf(elems)) { + elems = makeArray(elems); + } + + elems.forEach((elem) => { + elem.innerHTML = relativeAge(new Date(parseInt(elem.dataset.time, 10)), { + format: elem.dataset.format, + wrapInSpan: false, + }); }); } diff --git a/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js b/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js index 71cc9bc95a..959719c0c3 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/formatter-test.js @@ -10,6 +10,13 @@ import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; import sinon from "sinon"; import { test } from "qunit"; +function stringToDOMNode(string) { + let template = document.createElement("template"); + string = string.trim(); + template.innerHTML = string; + return template.content.firstChild; +} + function formatMins(mins, opts = {}) { let dt = new Date(new Date() - mins * 60 * 1000); return relativeAge(dt, { @@ -37,7 +44,7 @@ function shortDateTester(format) { } function strip(html) { - return $(html).text(); + return stringToDOMNode(html).innerText; } discourseModule("Unit | Utility | formatter", function (hooks) { @@ -108,10 +115,15 @@ discourseModule("Unit | Utility | formatter", function (hooks) { ); assert.equal( - $(formatDays(0, { format: "medium" })).attr("title"), + stringToDOMNode(formatDays(0, { format: "medium" })).title, longDate(new Date()) ); - assert.equal($(formatDays(0, { format: "medium" })).attr("class"), "date"); + + assert.ok( + stringToDOMNode(formatDays(0, { format: "medium" })).classList.contains( + "date" + ) + ); this.clock.restore(); this.clock = sinon.useFakeTimers(new Date(2012, 0, 9, 12, 0).getTime()); // Jan 9, 2012 @@ -205,49 +217,52 @@ discourseModule("Unit | Utility | formatter", function (hooks) { test("autoUpdatingRelativeAge", function (assert) { let d = moment().subtract(1, "day").toDate(); - let $elem = $(autoUpdatingRelativeAge(d)); - assert.equal($elem.data("format"), "tiny"); - assert.equal($elem.data("time"), d.getTime()); - assert.equal($elem.attr("title"), undefined); + let elem = stringToDOMNode(autoUpdatingRelativeAge(d)); + assert.equal(elem.dataset.format, "tiny"); + assert.equal(elem.dataset.time, d.getTime()); + assert.equal(elem.title, ""); - $elem = $(autoUpdatingRelativeAge(d, { title: true })); - assert.equal($elem.attr("title"), longDate(d)); + elem = stringToDOMNode(autoUpdatingRelativeAge(d, { title: true })); + assert.equal(elem.title, longDate(d)); - $elem = $( + elem = stringToDOMNode( autoUpdatingRelativeAge(d, { format: "medium", title: true, leaveAgo: true, }) ); - assert.equal($elem.data("format"), "medium-with-ago"); - assert.equal($elem.data("time"), d.getTime()); - assert.equal($elem.attr("title"), longDate(d)); - assert.equal($elem.html(), "1 day ago"); - $elem = $(autoUpdatingRelativeAge(d, { format: "medium" })); - assert.equal($elem.data("format"), "medium"); - assert.equal($elem.data("time"), d.getTime()); - assert.equal($elem.attr("title"), undefined); - assert.equal($elem.html(), "1 day"); + assert.equal(elem.dataset.format, "medium-with-ago"); + assert.equal(elem.dataset.time, d.getTime()); + assert.equal(elem.title, longDate(d)); + assert.equal(elem.innerHTML, "1 day ago"); + + elem = stringToDOMNode(autoUpdatingRelativeAge(d, { format: "medium" })); + assert.equal(elem.dataset.format, "medium"); + assert.equal(elem.dataset.time, d.getTime()); + assert.equal(elem.title, ""); + assert.equal(elem.innerHTML, "1 day"); }); test("updateRelativeAge", function (assert) { let d = new Date(); - let $elem = $(autoUpdatingRelativeAge(d)); - $elem.data("time", d.getTime() - 2 * 60 * 1000); + let elem = stringToDOMNode(autoUpdatingRelativeAge(d)); + elem.dataset.time = d.getTime() - 2 * 60 * 1000; - updateRelativeAge($elem); + updateRelativeAge(elem); - assert.equal($elem.html(), "2m"); + assert.equal(elem.innerHTML, "2m"); d = new Date(); - $elem = $(autoUpdatingRelativeAge(d, { format: "medium", leaveAgo: true })); - $elem.data("time", d.getTime() - 2 * 60 * 1000); + elem = stringToDOMNode( + autoUpdatingRelativeAge(d, { format: "medium", leaveAgo: true }) + ); + elem.dataset.time = d.getTime() - 2 * 60 * 1000; - updateRelativeAge($elem); + updateRelativeAge(elem); - assert.equal($elem.html(), "2 mins ago"); + assert.equal(elem.innerHTML, "2 mins ago"); }); test("number", function (assert) { From 9ac6f1d3bbf3a750619f16db3971e9d3beca48af Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 25 Oct 2021 12:53:50 +0100 Subject: [PATCH 035/254] FIX: Include the Vary:Accept header on all Accept-based responses (#14647) By default, Rails only includes the Vary:Accept header in responses when the Accept: header is included in the request. This means that proxies/browsers may cache a response to a request with a missing Accept header, and then later serve that cached version for a request which **does** supply the Accept header. This can lead to some very unexpected behavior in browsers. This commit adds the Vary:Accept header for all requests, even if the Accept header is not present in the request. If a format parameter (e.g. `.json` suffix) is included in the path, then the Accept header is still omitted. (The format parameter takes precedence over any Accept: header, so the response is no longer varies based on the Accept header) --- app/controllers/application_controller.rb | 2 ++ lib/vary_header.rb | 7 +++++++ spec/requests/application_controller_spec.rb | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 lib/vary_header.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bdb33fee0c..74eab9c71f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include GlobalPath include Hijack include ReadOnlyHeader + include VaryHeader attr_reader :theme_id @@ -46,6 +47,7 @@ class ApplicationController < ActionController::Base after_action :perform_refresh_session after_action :dont_cache_page after_action :conditionally_allow_site_embedding + after_action :ensure_vary_header HONEYPOT_KEY ||= 'HONEYPOT_KEY' CHALLENGE_KEY ||= 'CHALLENGE_KEY' diff --git a/lib/vary_header.rb b/lib/vary_header.rb new file mode 100644 index 0000000000..c80eb7a9b8 --- /dev/null +++ b/lib/vary_header.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module VaryHeader + def ensure_vary_header + response.headers['Vary'] ||= 'Accept' if !params[:format] + end +end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index bff3fdcaa9..610cebccbd 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -831,4 +831,24 @@ RSpec.describe ApplicationController do end end end + + describe 'vary header' do + it 'includes Vary:Accept on all requests where format is not explicit' do + # Rails default behaviour - include Vary:Accept when Accept is supplied + get "/latest", headers: { "Accept" => "application/json" } + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq("Accept") + + # Discourse additional behaviour (see lib/vary_header.rb) + # Include Vary:Accept even when Accept is not supplied + get "/latest" + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq("Accept") + + # Not needed, because the path 'format' parameter overrides the Accept header + get "/latest.json" + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq(nil) + end + end end From b02f003c68641557a8582ce1d97edffb1fec6561 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Oct 2021 14:41:12 +0200 Subject: [PATCH 036/254] Build(deps): Bump json from 2.6.0 to 2.6.1 (#14704) Bumps [json](https://github.com/flori/json) from 2.6.0 to 2.6.1. - [Release notes](https://github.com/flori/json/releases) - [Changelog](https://github.com/flori/json/blob/master/CHANGES.md) - [Commits](https://github.com/flori/json/compare/v2.6.0...v2.6.1) --- updated-dependencies: - dependency-name: json dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d5678b82de..4396f48390 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -187,7 +187,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.0) + json (2.6.1) json-schema (2.8.1) addressable (>= 2.4) json_schemer (0.2.18) From d6c39e54cf4133ea159f2b0f414367417e107266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Oct 2021 14:41:45 +0200 Subject: [PATCH 037/254] Build(deps): Bump rubocop from 1.22.1 to 1.22.2 (#14705) Bumps [rubocop](https://github.com/rubocop/rubocop) from 1.22.1 to 1.22.2. - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.22.1...v1.22.2) --- updated-dependencies: - dependency-name: rubocop dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4396f48390..0bcfc54eee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -389,7 +389,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.22.1) + rubocop (1.22.2) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) From facf7d6f566cac9fca921008dd15f35cedfbad77 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 25 Oct 2021 15:25:39 +0200 Subject: [PATCH 038/254] DEV: Don't leak auth methods between tests (#14708) --- .../javascripts/discourse/app/models/login-method.js | 10 ++++++---- .../discourse/tests/helpers/qunit-helpers.js | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/login-method.js b/app/assets/javascripts/discourse/app/models/login-method.js index bebb069b93..113482d8e0 100644 --- a/app/assets/javascripts/discourse/app/models/login-method.js +++ b/app/assets/javascripts/discourse/app/models/login-method.js @@ -85,10 +85,8 @@ export function findAll() { return methods; } - methods = []; - - Site.currentProp("auth_providers").forEach((provider) => - methods.pushObject(LoginMethod.create(provider)) + methods = Site.currentProp("auth_providers").map((provider) => + LoginMethod.create(provider) ); // exclude FA icon for Google, uses custom SVG @@ -97,4 +95,8 @@ export function findAll() { return methods; } +export function clearAuthMethods() { + methods = undefined; +} + export default LoginMethod; diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index d2c64d0082..115de3833c 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -52,6 +52,7 @@ import { cleanUpComposerUploadProcessor, } from "discourse/components/composer-editor"; import { resetLastEditNotificationClick } from "discourse/models/post-stream"; +import { clearAuthMethods } from "discourse/models/login-method"; const LEGACY_ENV = !setupApplicationTest; @@ -295,6 +296,8 @@ export function acceptance(name, optionsOrCallback) { cleanUpComposerUploadMarkdownResolver(); cleanUpComposerUploadPreProcessor(); resetLastEditNotificationClick(); + clearAuthMethods(); + app._runInitializer("instanceInitializers", (initName, initializer) => { if (initializer && initializer.teardown) { initializer.teardown(this.container); From 116982fca9beb885f20e51d13ab03d8f246caa03 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Mon, 25 Oct 2021 15:05:00 -0400 Subject: [PATCH 039/254] FIX: Correct tracking context for some category routes (#14685) We were previously showing the "n new or updated topics" alert on category routes like `/c/category-slug/ID/none` on every new/unread topic update. This PR looks up the category by ID, which should be more precise. --- .../app/models/topic-tracking-state.js | 14 ++++--- .../unit/models/topic-tracking-state-test.js | 41 ++++++++++++------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js index 5013347f8f..a3b1ccc6bf 100644 --- a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js +++ b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js @@ -284,13 +284,15 @@ const TopicTrackingState = EmberObject.extend({ trackIncoming(filter) { this.newIncoming = []; - const split = filter.split("/"); - if (split.length >= 4) { - filter = split[split.length - 1]; - let category = Category.findSingleBySlug( - split.splice(1, split.length - 4).join("/") - ); + if (filter.startsWith("c/")) { + const categoryId = filter.match(/\/(\d*)\//); + const category = Category.findById(parseInt(categoryId[1], 10)); this.set("filterCategory", category); + + const split = filter.split("/"); + if (split.length >= 4) { + filter = split[split.length - 1]; + } } else { this.set("filterCategory", null); } diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js index b2ffb56fb1..f5ec6ac961 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js @@ -745,20 +745,9 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }); test("subscribe to category", function (assert) { - const store = createStore(); - const darth = store.createRecord("category", { id: 1, slug: "darth" }), - luke = store.createRecord("category", { - id: 2, - slug: "luke", - parentCategory: darth, - }), - categoryList = [darth, luke]; - - sinon.stub(Category, "list").returns(categoryList); - const trackingState = TopicTrackingState.create(); - trackingState.trackIncoming("c/darth/1/l/latest"); + trackingState.trackIncoming("c/feature/2/l/latest"); trackingState.notifyIncoming({ message_type: "new_topic", @@ -773,7 +762,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { trackingState.notifyIncoming({ message_type: "new_topic", topic_id: 3, - payload: { category_id: 1 }, + payload: { category_id: 26 }, }); assert.equal( @@ -783,7 +772,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { ); trackingState.resetTracking(); - trackingState.trackIncoming("c/darth/luke/2/l/latest"); + trackingState.trackIncoming("c/feature/spec/26/l/latest"); trackingState.notifyIncoming({ message_type: "new_topic", @@ -795,10 +784,17 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { topic_id: 2, payload: { category_id: 3 }, }); + + assert.equal( + trackingState.get("incomingCount"), + 0, + "parent or other category doesn't affect subcategory" + ); + trackingState.notifyIncoming({ message_type: "new_topic", topic_id: 3, - payload: { category_id: 1 }, + payload: { category_id: 26 }, }); assert.equal( @@ -806,6 +802,21 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { 1, "expect to properly track incoming for subcategory" ); + + trackingState.resetTracking(); + trackingState.trackIncoming("c/feature/spec/26/none/l/latest"); + + trackingState.notifyIncoming({ + message_type: "new_topic", + topic_id: 3, + payload: { category_id: 26 }, + }); + + assert.equal( + trackingState.get("incomingCount"), + 1, + "expect to properly track incoming for subcategory using none tags route" + ); }); test("getSubCategoryIds", function (assert) { From dd5b0543c7f8f4d4d5d4849cce98a64ae3f55f53 Mon Sep 17 00:00:00 2001 From: janzenisaac <50783505+janzenisaac@users.noreply.github.com> Date: Mon, 25 Oct 2021 14:06:28 -0500 Subject: [PATCH 040/254] FEATURE: Add fullscreen-tables to post (#14709) Overflown tables will have a "expand table" option added to open x table in a modal --- .../app/initializers/post-decorations.js | 61 ++++++++++++++++++- .../app/templates/modal/fullscreen-table.hbs | 3 + .../stylesheets/common/base/topic-post.scss | 53 ++++++++++++++++ app/assets/stylesheets/mobile/topic-post.scss | 5 ++ config/locales/client.en.yml | 3 + 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/templates/modal/fullscreen-table.hbs diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index 8a75560868..5b04730dd0 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -1,11 +1,13 @@ -import { later } from "@ember/runloop"; +import { later, schedule } from "@ember/runloop"; import I18n from "I18n"; import highlightSyntax from "discourse/lib/highlight-syntax"; import lightbox from "discourse/lib/lightbox"; -import { iconHTML } from "discourse-common/lib/icon-library"; +import { iconHTML, iconNode } from "discourse-common/lib/icon-library"; import { setTextDirections } from "discourse/lib/text-direction"; import { nativeLazyLoading } from "discourse/lib/lazy-load-images"; import { withPluginApi } from "discourse/lib/plugin-api"; +import { create } from "virtual-dom"; +import showModal from "discourse/lib/show-modal"; export default { name: "post-decorations", @@ -131,6 +133,61 @@ export default { }, { id: "discourse-video-codecs" } ); + + function _createButton() { + const openPopupBtn = document.createElement("button"); + openPopupBtn.classList.add( + "open-popup-link", + "btn-default", + "btn", + "btn-icon-text" + ); + const expandIcon = create( + iconNode("discourse-expand", { class: "expand-table-icon" }) + ); + const openPopupText = document.createTextNode( + I18n.t("fullscreen_table.expand_btn") + ); + openPopupBtn.append(expandIcon, openPopupText); + return openPopupBtn; + } + + function isOverflown({ clientWidth, scrollWidth }) { + return scrollWidth > clientWidth; + } + + function generateModal(event) { + const table = event.target.parentNode.querySelector("table"); + const tempTable = table.cloneNode(true); + + showModal("fullscreen-table").set("tableHtml", tempTable); + } + + function generatePopups(tables) { + tables.forEach((table) => { + if (!isOverflown(table.parentNode)) { + return; + } + + const popupBtn = _createButton(); + table.parentNode.classList.add("fullscreen-table-wrapper"); + table.parentNode.insertBefore(popupBtn, table); + popupBtn.addEventListener("click", generateModal, false); + }); + } + + api.decorateCookedElement( + (post) => { + schedule("afterRender", () => { + const tables = post.querySelectorAll("table"); + generatePopups(tables); + }); + }, + { + onlyStream: true, + id: "fullscreen-table", + } + ); }); }, }; diff --git a/app/assets/javascripts/discourse/app/templates/modal/fullscreen-table.hbs b/app/assets/javascripts/discourse/app/templates/modal/fullscreen-table.hbs new file mode 100644 index 0000000000..37a0cf25fc --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/fullscreen-table.hbs @@ -0,0 +1,3 @@ +{{#d-modal-body}} + {{tableHtml}} +{{/d-modal-body}} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index cbaee44f12..e3b6b1e2fd 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -1410,3 +1410,56 @@ a.mention-group { opacity: 0; } } + +.open-popup-link { + position: sticky; + left: 0.5rem; + top: 0.5rem; + opacity: 0%; + white-space: nowrap; + display: block; +} + +.fullscreen-table-wrapper { + transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + display: block; +} + +.expand-table-icon { + margin-right: 4px; +} + +.fullscreen-table-modal .modal-inner-container { + width: max-content; + max-width: 90%; + margin: 0 auto; + padding: 10px; + + .modal-body { + padding-top: 0; + } + + thead { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--secondary); + } + + tbody { + overflow-x: hidden; + } + + td { + padding: 0.5rem; + } +} + +html.discourse-no-touch .fullscreen-table-wrapper:hover { + border-radius: 5px; + box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1), + 0 2px 10px 0 rgba(var(--always-black-rgb), 0.1); + .open-popup-link { + opacity: 100%; + } +} diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index ebfe9441a4..b334d8732a 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -458,3 +458,8 @@ span.highlighted { } } } + +.open-popup-link { + opacity: 100%; + margin-bottom: 1rem; +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d3690731d2..67b081f45c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3938,6 +3938,9 @@ en: no_group_messages_title: "No group messages found" + fullscreen_table: + expand_btn: "Expand Table" + # This section is exported to the javascript for i18n in the admin section admin_js: type_to_filter: "type to filter..." From 436edbb51a15c634e65c0d7cdccdb4ce9aecd8f1 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Mon, 25 Oct 2021 16:24:47 -0300 Subject: [PATCH 041/254] DEV: Only set `tap_failed_tests_only` to true for CI. (#14710) It may seem like the command crashed when running tests locally since we get no feedback until it finishes running all the tests. --- app/assets/javascripts/discourse/testem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/testem.js b/app/assets/javascripts/discourse/testem.js index e6ef5e4320..50b41a2852 100644 --- a/app/assets/javascripts/discourse/testem.js +++ b/app/assets/javascripts/discourse/testem.js @@ -27,7 +27,7 @@ module.exports = { disable_watching: true, launch_in_ci: ["Chrome", "Firefox", "Headless Firefox"], // Firefox is old ESR version, Headless Firefox is up-to-date evergreen version launch_in_dev: ["Chrome"], - tap_failed_tests_only: true, + tap_failed_tests_only: process.env.CI, parallel: 1, // disable parallel tests for stability browser_start_timeout: 120, browser_args: { From f6528afa019ded81012947efdf2835324488183b Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 26 Oct 2021 11:22:33 +1000 Subject: [PATCH 042/254] DEV: Add uploadHandler support to composer-upload-uppy mixin (#14692) This commit adds uploadHandler support to composer uploads using uppy. The only things we have that are using this are discourse-brightcove and discourse-video, which both pop modal windows to handle the file upload and completely leave out all the composer-type flows. This implementation simply follows the existing one, where if a single file is uploaded and there is a matching upload handler we take control away from uppy and hand it off to the upload handler. Trying to get this kind of thing working within uppy would require a few changes because they have no way to restrict uploaders to certain file types and with the way their uploaders are run it doesn't look like it would be easy to add this either, so I don't think this is worth the work unless at some point in the future we plan to have more upload handler integrations. I also fixed an issue with `cleanUpComposerUploadHandler` which is used in tests to reset the state of `uploadHandlers` in the composer. This was doing `uploadHandlers = []` to clear that array, but that creates a brand new array so anything else referencing the original array will lose that reference. Better to set `uploadHandlers.length = 0` to clear it. This was breaking the tests I added to see if upload handlers were working. --- .../app/components/composer-editor.js | 17 +++++- .../app/mixins/composer-upload-uppy.js | 24 +++++++- .../discourse/app/mixins/composer-upload.js | 16 ++--- .../acceptance/composer-attachment-test.js | 59 +++++++++++++++---- .../acceptance/composer-uploads-uppy-test.js | 38 ++++++++++++ 5 files changed, 127 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index 1e01a57b27..2aed00dae3 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -48,7 +48,14 @@ export function addComposerUploadHandler(extensions, method) { }); } export function cleanUpComposerUploadHandler() { - uploadHandlers = []; + // we cannot set this to uploadHandlers = [] because that messes with + // the references to the original array that the component has. this only + // really affects tests, but without doing this you could addComposerUploadHandler + // in a beforeEach function in a test but then it's not adding to the + // existing reference that the component has, because an earlier test ran + // cleanUpComposerUploadHandler and lost it. setting the length to 0 empties + // the array but keeps the reference + uploadHandlers.length = 0; } let uploadProcessorQueue = []; @@ -688,6 +695,14 @@ export default Component.extend(ComposerUpload, { } }, + _findMatchingUploadHandler(fileName) { + return this.uploadHandlers.find((handler) => { + const ext = handler.extensions.join("|"); + const regex = new RegExp(`\\.(${ext})$`, "i"); + return regex.test(fileName); + }); + }, + actions: { importQuote(toolbarEvent) { this.importQuote(toolbarEvent); 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 c5f1e08b67..1538efad62 100644 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js +++ b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js @@ -67,6 +67,12 @@ export default Mixin.create(ExtendableUploader, { } }, + _abortAndReset() { + this.appEvents.trigger(`${this.eventPrefix}:uploads-aborted`); + this._reset(); + return false; + }, + _bindUploadTarget() { this.placeholders = {}; this._inProgressUploads = 0; @@ -118,6 +124,20 @@ export default Mixin.create(ExtendableUploader, { const fileCount = Object.keys(files).length; const maxFiles = this.siteSettings.simultaneous_uploads; + // Look for a matching file upload handler contributed from a plugin. + // It is not ideal that this only works for single file uploads, but + // at this time it is all we need. In future we may want to devise a + // nicer way of doing this. Uppy plugins are out of the question because + // there is no way to define which uploader plugin handles which file + // extensions at this time. + if (fileCount === 1) { + const file = Object.values(files)[0]; + const matchingHandler = this._findMatchingUploadHandler(file.name); + if (matchingHandler && !matchingHandler.method(file.data, this)) { + return this._abortAndReset(); + } + } + // Limit the number of simultaneous uploads if (maxFiles > 0 && fileCount > maxFiles) { bootbox.alert( @@ -125,9 +145,7 @@ export default Mixin.create(ExtendableUploader, { count: maxFiles, }) ); - this.appEvents.trigger(`${this.eventPrefix}:uploads-aborted`); - this._reset(); - return false; + return this._abortAndReset(); } }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload.js b/app/assets/javascripts/discourse/app/mixins/composer-upload.js index 7c5446c5b0..24fae9e186 100644 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload.js +++ b/app/assets/javascripts/discourse/app/mixins/composer-upload.js @@ -199,9 +199,10 @@ export default Mixin.create({ $element.on("fileuploadsubmit", (e, data) => { const max = this.siteSettings.simultaneous_uploads; + const fileCount = data.files.length; // Limit the number of simultaneous uploads - if (max > 0 && data.files.length > max) { + if (max > 0 && fileCount > max) { bootbox.alert( I18n.t("post.errors.too_many_dragged_and_dropped_files", { count: max, @@ -211,15 +212,10 @@ export default Mixin.create({ } // Look for a matching file upload handler contributed from a plugin - const matcher = (handler) => { - const ext = handler.extensions.join("|"); - const regex = new RegExp(`\\.(${ext})$`, "i"); - return regex.test(data.files[0].name); - }; - - const matchingHandler = this.uploadHandlers.find(matcher); - if (data.files.length === 1 && matchingHandler) { - if (!matchingHandler.method(data.files[0], this)) { + if (fileCount === 1) { + const file = data.files[0]; + const matchingHandler = this._findMatchingUploadHandler(file.name); + if (matchingHandler && !matchingHandler.method(file, this)) { return false; } } diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js index c4c21dfc9d..1fe8845f4e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js @@ -4,7 +4,9 @@ import { query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import { withPluginApi } from "discourse/lib/plugin-api"; import { click, fillIn, visit } from "@ember/test-helpers"; +import bootbox from "bootbox"; import { test } from "qunit"; function pretender(server, helper) { @@ -239,20 +241,51 @@ acceptance("Composer Attachment - Upload Placeholder", function (needs) { "![ima++ge|300x400](/images/avatar.png?4)\n" ); }); +}); - function createImage(name, url, width, height) { - const file = new Blob([""], { type: "image/png" }); - file.name = name; - return { - files: [file], - result: { - original_filename: name, - thumbnail_width: width, - thumbnail_height: height, - url: url, - }, - }; - } +function createImage(name, url, width, height) { + const file = new Blob([""], { type: "image/png" }); + file.name = name; + return { + files: [file], + result: { + original_filename: name, + thumbnail_width: width, + thumbnail_height: height, + url, + }, + }; +} + +acceptance("Composer Attachment - Upload Handler", function (needs) { + needs.user(); + needs.hooks.beforeEach(() => { + withPluginApi("0.8.14", (api) => { + api.addComposerUploadHandler(["png"], (file) => { + bootbox.alert(`This is an upload handler test for ${file.name}`); + }); + }); + }); + + test("should handle a single file being uploaded with the extension handler", async function (assert) { + await visit("/"); + await click("#create-topic"); + const image = createImage( + "handlertest.png", + "/images/avatar.png?1", + 200, + 300 + ); + await fillIn(".d-editor-input", "This is a handler test."); + + await queryAll(".wmd-controls").trigger("fileuploadsubmit", image); + assert.equal( + queryAll(".bootbox .modal-body").html(), + "This is an upload handler test for handlertest.png", + "it should show the bootbox triggered by the upload handler" + ); + await click(".modal-footer .btn"); + }); }); acceptance("Composer Attachment - File input", function (needs) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js index 6b94f0315d..8148f1b4ec 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js @@ -3,6 +3,8 @@ import { loggedInUser, queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import bootbox from "bootbox"; import { authorizedExtensions } from "discourse/lib/uploads"; import { click, fillIn, visit } from "@ember/test-helpers"; import I18n from "I18n"; @@ -222,3 +224,39 @@ acceptance("Uppy Composer Attachment - Upload Error", function (needs) { appEvents.trigger("composer:add-files", image); }); }); + +acceptance("Uppy Composer Attachment - Upload Handler", function (needs) { + needs.user(); + needs.pretender(pretender); + needs.settings({ + enable_experimental_composer_uploader: true, + simultaneous_uploads: 2, + }); + needs.hooks.beforeEach(() => { + withPluginApi("0.8.14", (api) => { + api.addComposerUploadHandler(["png"], (file) => { + bootbox.alert(`This is an upload handler test for ${file.name}`); + }); + }); + }); + + test("should use upload handler if the matching extension is used and a single file is uploaded", async function (assert) { + await visit("/"); + await click("#create-topic"); + const image = createFile("handlertest.png"); + const appEvents = loggedInUser().appEvents; + const done = assert.async(); + + appEvents.on("composer:uploads-aborted", async () => { + assert.equal( + queryAll(".bootbox .modal-body").html(), + "This is an upload handler test for handlertest.png", + "it should show the bootbox triggered by the upload handler" + ); + await click(".modal-footer .btn"); + done(); + }); + + appEvents.trigger("composer:add-files", [image]); + }); +}); From f003e31e2ff0bec962c4e8010a46906c9e789203 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Tue, 26 Oct 2021 10:16:38 +0300 Subject: [PATCH 043/254] PERF: Optimize search in private messages query (#14660) * PERF: Remove JOIN on categories for PM search JOIN on categories is not needed when searchin in private messages as PMs are not categorized. * DEV: Use == for string comparison * PERF: Optimize query for allowed topic groups There was a query that checked for all topics a user or their groups were allowed to see. This used UNION between topic_allowed_users and topic_allowed_groups which was very inefficient. That was replaced with a OR condition that checks in either tables more efficiently. --- app/models/post.rb | 10 +++-- app/models/topic.rb | 16 ++++--- lib/search.rb | 103 +++++++++++++++++++++++--------------------- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/app/models/post.rb b/app/models/post.rb index 86bb006c7a..5a8488e2ad 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -84,9 +84,13 @@ class Post < ActiveRecord::Base register_custom_field_type(NOTICE, :json) - scope :private_posts_for_user, ->(user) { - where("posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL})", user_id: user.id) - } + scope :private_posts_for_user, ->(user) do + where( + "posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER}) + OR posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})", + user_id: user.id + ) + end scope :by_newest, -> { order('created_at DESC, id DESC') } scope :by_post_number, -> { order('post_number ASC') } diff --git a/app/models/topic.rb b/app/models/topic.rb index 81d0d56ad8..fe369fd84f 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -267,19 +267,25 @@ class Topic < ActiveRecord::Base # Return private message topics scope :private_messages, -> { where(archetype: Archetype.private_message) } - PRIVATE_MESSAGES_SQL = <<~SQL + PRIVATE_MESSAGES_SQL_USER = <<~SQL SELECT topic_id FROM topic_allowed_users WHERE user_id = :user_id - UNION ALL + SQL + + PRIVATE_MESSAGES_SQL_GROUP = <<~SQL SELECT tg.topic_id FROM topic_allowed_groups tg JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id SQL - scope :private_messages_for_user, ->(user) { - private_messages.where("topics.id IN (#{PRIVATE_MESSAGES_SQL})", user_id: user.id) - } + scope :private_messages_for_user, ->(user) do + private_messages.where( + "topics.id IN (#{PRIVATE_MESSAGES_SQL_USER}) + OR topics.id IN (#{PRIVATE_MESSAGES_SQL_GROUP})", + user_id: user.id + ) + end scope :listable_topics, -> { where('topics.archetype <> ?', Archetype.private_message) } diff --git a/lib/search.rb b/lib/search.rb index 231b5df29f..516660b341 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -895,12 +895,15 @@ class Search def posts_query(limit, type_filter: nil, aggregate_search: false) posts = Post.where(post_type: Topic.visible_post_types(@guardian.user)) .joins(:post_search_data, :topic) - .joins("LEFT JOIN categories ON categories.id = topics.category_id") + + if type_filter != "private_messages" + posts = posts.joins("LEFT JOIN categories ON categories.id = topics.category_id") + end is_topic_search = @search_context.present? && @search_context.is_a?(Topic) posts = posts.where("topics.visible") unless is_topic_search - if type_filter === "private_messages" || (is_topic_search && @search_context.private_message?) + if type_filter == "private_messages" || (is_topic_search && @search_context.private_message?) posts = posts .where( "topics.archetype = ? AND post_search_data.private_message", @@ -910,7 +913,7 @@ class Search unless @guardian.is_admin? posts = posts.private_posts_for_user(@guardian.user) end - elsif type_filter === "all_topics" + elsif type_filter == "all_topics" private_posts = posts .where( "topics.archetype = ? AND post_search_data.private_message", @@ -973,7 +976,7 @@ class Search posts = if @search_context.present? if @search_context.is_a?(User) - if type_filter === "private_messages" + if type_filter == "private_messages" if @guardian.is_admin? && !@search_all_pms posts.private_posts_for_user(@search_context) else @@ -1036,57 +1039,61 @@ class Search ) SQL - category_search_priority = <<~SQL - ( - CASE categories.search_priority - WHEN #{Searchable::PRIORITIES[:very_high]} - THEN 3 - WHEN #{Searchable::PRIORITIES[:very_low]} - THEN 1 - ELSE 2 - END - ) - SQL - - category_priority_weights = <<~SQL - ( - CASE categories.search_priority - WHEN #{Searchable::PRIORITIES[:low]} - THEN #{SiteSetting.category_search_priority_low_weight} - WHEN #{Searchable::PRIORITIES[:high]} - THEN #{SiteSetting.category_search_priority_high_weight} - ELSE - CASE WHEN topics.closed - THEN 0.9 - ELSE 1 + if type_filter != "private_messages" + category_search_priority = <<~SQL + ( + CASE categories.search_priority + WHEN #{Searchable::PRIORITIES[:very_high]} + THEN 3 + WHEN #{Searchable::PRIORITIES[:very_low]} + THEN 1 + ELSE 2 END - END - ) - SQL + ) + SQL - data_ranking = - if @term.blank? - "(#{category_priority_weights})" - else - "(#{rank} * #{category_priority_weights})" - end + category_priority_weights = <<~SQL + ( + CASE categories.search_priority + WHEN #{Searchable::PRIORITIES[:low]} + THEN #{SiteSetting.category_search_priority_low_weight} + WHEN #{Searchable::PRIORITIES[:high]} + THEN #{SiteSetting.category_search_priority_high_weight} + ELSE + CASE WHEN topics.closed + THEN 0.9 + ELSE 1 + END + END + ) + SQL - posts = - if aggregate_search - posts.order("MAX(#{category_search_priority}) DESC", "MAX(#{data_ranking}) DESC") - else - posts.order("#{category_search_priority} DESC", "#{data_ranking} DESC") - end + data_ranking = + if @term.blank? + "(#{category_priority_weights})" + else + "(#{rank} * #{category_priority_weights})" + end + + posts = + if aggregate_search + posts.order("MAX(#{category_search_priority}) DESC", "MAX(#{data_ranking}) DESC") + else + posts.order("#{category_search_priority} DESC", "#{data_ranking} DESC") + end + end posts = posts.order("topics.bumped_at DESC") end - posts = - if secure_category_ids.present? - posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted) OR (categories.id IN (?))", secure_category_ids).references(:categories) - else - posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted)").references(:categories) - end + if type_filter != "private_messages" + posts = + if secure_category_ids.present? + posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted) OR (categories.id IN (?))", secure_category_ids).references(:categories) + else + posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted)").references(:categories) + end + end if @order advanced_order = Search.advanced_orders&.fetch(@order, nil) From 93183b18e0498aa18ccef4de310425411e668bd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Oct 2021 12:09:03 +0200 Subject: [PATCH 044/254] Build(deps): Bump ipaddr from 1.2.2 to 1.2.3 (#14711) Bumps [ipaddr](https://github.com/ruby/ipaddr) from 1.2.2 to 1.2.3. - [Release notes](https://github.com/ruby/ipaddr/releases) - [Changelog](https://github.com/ruby/ipaddr/blob/master/CHANGELOG.md) - [Commits](https://github.com/ruby/ipaddr/compare/v1.2.2...v1.2.3) --- updated-dependencies: - dependency-name: ipaddr dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0bcfc54eee..dae76aa741 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,7 +181,7 @@ GEM progress (~> 3.0, >= 3.0.1) image_size (3.0.1) in_threads (1.5.4) - ipaddr (1.2.2) + ipaddr (1.2.3) jmespath (1.4.0) jquery-rails (4.4.0) rails-dom-testing (>= 1, < 3) From 3bae85e203139d7c37dee12795db3ddf990a4c62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Oct 2021 12:09:20 +0200 Subject: [PATCH 045/254] Build(deps): Bump omniauth-facebook from 8.0.0 to 9.0.0 (#14712) Bumps [omniauth-facebook](https://github.com/simi/omniauth-facebook) from 8.0.0 to 9.0.0. - [Release notes](https://github.com/simi/omniauth-facebook/releases) - [Changelog](https://github.com/simi/omniauth-facebook/blob/master/CHANGELOG.md) - [Commits](https://github.com/simi/omniauth-facebook/compare/v8.0.0...v9.0.0) --- updated-dependencies: - dependency-name: omniauth-facebook dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index dae76aa741..a283ea8e1f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -265,7 +265,7 @@ GEM omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) - omniauth-facebook (8.0.0) + omniauth-facebook (9.0.0) omniauth-oauth2 (~> 1.2) omniauth-github (1.4.0) omniauth (~> 1.5) From 9882aa840aa42632d78fe3896f5e745b116f2c43 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 11:24:10 +0100 Subject: [PATCH 046/254] UX: Re-order auth-related site settings for clarity (#14716) This commit groups `auth_overrides_*`, `discourse_connect_*` and `discourse_connect_provider_*` settings separately, rather than interspersing them. There will be no functional change. This only affects the order in which they're shown in the admin panel --- config/site_settings.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index e78f38fd37..fb70d5ed80 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -449,12 +449,17 @@ login: client: true auth_immediately: default: true + auth_overrides_email: + default: false + validator: "SsoOverridesEmailValidator" + client: true + auth_overrides_username: false + auth_overrides_name: false enable_discourse_connect: client: true default: false validator: "EnableSsoValidator" discourse_connect_allows_all_return_paths: false - enable_discourse_connect_provider: false verbose_discourse_connect_logging: false verbose_upload_logging: hidden: true @@ -471,22 +476,8 @@ login: discourse_connect_secret: default: "" secret: true - discourse_connect_provider_secrets: - default: "" - type: list - list_type: secret - secret: true - placeholder: - key: "sso_provider.key_placeholder" - value: "sso_provider.value_placeholder" discourse_connect_overrides_groups: false discourse_connect_overrides_bio: false - auth_overrides_email: - default: false - validator: "SsoOverridesEmailValidator" - client: true - auth_overrides_username: false - auth_overrides_name: false discourse_connect_overrides_avatar: default: false client: true @@ -498,6 +489,15 @@ login: discourse_connect_csrf_protection: default: true hidden: true + enable_discourse_connect_provider: false + discourse_connect_provider_secrets: + default: "" + type: list + list_type: secret + secret: true + placeholder: + key: "sso_provider.key_placeholder" + value: "sso_provider.value_placeholder" blocked_email_domains: default: "mailinator.com" type: list From be38615afde03371a4b5cbb7eff364fa680de93b Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 26 Oct 2021 09:46:25 -0300 Subject: [PATCH 047/254] FEATURE: Mark omniauth failures as HTML safe. (#14713) Plugins can add HTML elements to auth error messages. --- app/controllers/users/omniauth_callbacks_controller.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 6a9253b6f3..7850cf2c67 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -98,8 +98,14 @@ class Users::OmniauthCallbacksController < ApplicationController end def failure - error_key = params[:message].to_s.gsub(/[^\w-]/, "") || "generic" - flash[:error] = I18n.t("login.omniauth_error.#{error_key}", default: I18n.t("login.omniauth_error.generic")) + error_key = params[:message].to_s.gsub(/[^\w-]/, "") + error_key = "generic" if error_key.blank? + + flash[:error] = I18n.t( + "login.omniauth_error.#{error_key}", + default: I18n.t("login.omniauth_error.generic") + ).html_safe + render 'failure' end From dfb79241054b49953d5d3f0ad7da972987a0d78b Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 26 Oct 2021 11:10:22 -0300 Subject: [PATCH 048/254] DEV: Specify target browsers when running Ember CLI tests from the docker rake task (#14720) --- lib/tasks/docker.rake | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake index 5d66206055..e078e5b28b 100644 --- a/lib/tasks/docker.rake +++ b/lib/tasks/docker.rake @@ -19,6 +19,7 @@ # => JS_TIMEOUT set timeout for qunit tests in ms # => WARMUP_TMP_FOLDER runs a single spec to warmup the tmp folder and obtain accurate results when profiling specs. # => EMBER_CLI set to 1 to run JS tests using the Ember CLI +# => EMBER_CLI_BROWSERS comma separated list of browsers to test against. Options are Chrome, Firefox, and Headless Firefox. # # Other useful environment variables (not specific to this rake task) # => COMMIT_HASH used by the discourse_test docker image to load a specific commit of discourse @@ -225,8 +226,9 @@ task 'docker:test' do if ENV["EMBER_CLI"] Dir.chdir("#{Rails.root}/app/assets/javascripts/discourse") do # rubocop:disable Discourse/NoChdir + browsers = ENV["EMBER_CLI_BROWSERS"] || 'Chrome' @good &&= run_or_fail("yarn install") - @good &&= run_or_fail("yarn ember test") + @good &&= run_or_fail("yarn ember test --launch #{browsers}") end end end From 0c6f9d7c679166889d36b00183765cc122f10cee Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Tue, 26 Oct 2021 18:31:15 +0300 Subject: [PATCH 049/254] FIX: Show right message when permanently deleting topic (#14717) --- app/models/topic.rb | 18 +++++++++--------- lib/guardian/topic_guardian.rb | 2 +- spec/models/post_spec.rb | 18 ++++++++++++++++++ spec/models/topic_spec.rb | 30 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index fe369fd84f..2dd6f955fa 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1755,6 +1755,15 @@ class Topic < ActiveRecord::Base ).performed! end + def cannot_permanently_delete_reason(user) + if self.posts_count > 0 + I18n.t('post.cannot_permanently_delete.many_posts') + elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago + time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i) + I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left) + end + end + private def invite_to_private_message(invited_by, target_user, guardian) @@ -1815,15 +1824,6 @@ class Topic < ActiveRecord::Base def apply_per_day_rate_limit_for(key, method_name) RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i) end - - def cannot_permanently_delete_reason(user) - if self.posts_count > 1 - I18n.t('post.cannot_permanently_delete.many_posts') - elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago - time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i) - I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left) - end - end end # == Schema Information diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 1d33bfdb24..ef619bd112 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -156,7 +156,7 @@ module TopicGuardian def can_permanently_delete_topic?(topic) return false if !SiteSetting.can_permanently_delete return false if !topic - return false if topic.posts_count > 1 + return false if topic.posts_count > 0 return false if !is_admin? || !can_see_topic?(topic) return false if !topic.deleted_at return false if topic.deleted_by_id == @user.id && topic.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 879eca29e1..ccd9e20481 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -1755,4 +1755,22 @@ describe Post do post.publish_change_to_clients!(:created) end end + + describe "#cannot_permanently_delete_reason" do + fab!(:post) { Fabricate(:post) } + fab!(:admin) { Fabricate(:admin) } + + before do + freeze_time + PostDestroyer.new(admin, post).destroy + end + + it 'returns error message if same admin and time did not pass' do + expect(post.cannot_permanently_delete_reason(admin)).to eq(I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i))) + end + + it 'returns nothing if different admin' do + expect(post.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 40d9612578..e356d11d19 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -2936,4 +2936,34 @@ describe Topic do end end end + + describe "#cannot_permanently_delete_reason" do + fab!(:post) { Fabricate(:post) } + let!(:topic) { post.topic } + fab!(:admin) { Fabricate(:admin) } + + before do + freeze_time + end + + it 'returns error message if topic has more posts' do + post_2 = PostCreator.create!(Fabricate(:user), topic_id: topic.id, raw: 'some post content') + + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(I18n.t('post.cannot_permanently_delete.many_posts')) + + PostDestroyer.new(admin, post_2).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + + it 'returns error message if same admin and time did not pass' do + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(admin)).to eq(I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i))) + end + + it 'returns nothing if different admin' do + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + end end From b24002018a10e12b7bdee3a8f5218345716af6ff Mon Sep 17 00:00:00 2001 From: Jeremy Waters Date: Tue, 26 Oct 2021 11:59:53 -0400 Subject: [PATCH 050/254] Update phorum.rb Add attachment/file/upload handling to bring them in from phorum to discourse --- script/import_scripts/phorum.rb | 70 ++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/script/import_scripts/phorum.rb b/script/import_scripts/phorum.rb index 3dc4e80b04..e18d60f85c 100644 --- a/script/import_scripts/phorum.rb +++ b/script/import_scripts/phorum.rb @@ -25,6 +25,7 @@ class ImportScripts::Phorum < ImportScripts::Base import_users import_categories import_posts + import_attachments end def import_users @@ -34,7 +35,7 @@ class ImportScripts::Phorum < ImportScripts::Base batches(BATCH_SIZE) do |offset| results = mysql_query( - "SELECT user_id id, username, email, real_name name, date_added created_at, + "SELECT user_id id, username, TRIM(email) AS email, username name, date_added created_at, date_last_active last_seen_at, admin FROM #{TABLE_PREFIX}users WHERE #{TABLE_PREFIX}users.active = 1 @@ -208,6 +209,9 @@ class ImportScripts::Phorum < ImportScripts::Base s.gsub!(/\[\/color\]/i, "") s.gsub!(/\[hr\]/i, "
") + + # remove trailing
+ s = s.chomp("
") s end @@ -215,6 +219,70 @@ class ImportScripts::Phorum < ImportScripts::Base def mysql_query(sql) @client.query(sql, cache_rows: false) end + + def import_attachments + puts '', 'importing attachments...' + + uploads = mysql_query <<-SQL + SELECT message_id, filename, FROM_BASE64(file_data) AS file_data, file_id + FROM #{TABLE_PREFIX}files + where message_id > 0 + order by file_id + SQL + + current_count = 0 + total_count = uploads.count + + uploads.each do |upload| + + # puts "*** processing file #{upload['file_id']}" + + post_id = post_id_from_imported_post_id(upload['message_id']) + + if post_id.nil? + puts "Post #{upload['message_id']} for attachment #{upload['file_id']} not found" + next + end + + post = Post.find(post_id) + + real_filename = upload['filename'] + real_filename.prepend SecureRandom.hex if real_filename[0] == '.' + + tmpfile = 'attach_' + upload['file_id'].to_s + filename = File.join('/tmp/', tmpfile) + File.open(filename, 'wb') { |f| + f.write(upload['file_data']) + } + + upl_obj = create_upload(post.user.id, filename, real_filename) + + # puts "discourse post #{post['id']} and upload #{upl_obj['id']}" + + if upl_obj&.persisted? + html = html_for_upload(upl_obj, real_filename) + if !post.raw[html] + post.raw += "\n\n#{html}\n\n" + post.save! + if PostUpload.where(post: post, upload: upl_obj).exists? + puts "skipping creating uploaded for previously uploaded file #{upload['file_id']}" + else + PostUpload.create!(post: post, upload: upl_obj) + end + # PostUpload.create!(post: post, upload: upl_obj) unless PostUpload.where(post: post, upload: upl_obj).exists? + else + puts "Skipping attachment #{upload['file_id']}" + end + else + puts "Failed to upload attachment #{upload['file_id']}" + exit + end + + current_count += 1 + print_status(current_count, total_count) + end + end + end ImportScripts::Phorum.new.perform From 54ca7a9618bd8b10ce43e1fad92c4c36dd0ca581 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 16:49:45 +0100 Subject: [PATCH 051/254] FIX: Do not send presence leave beacon if not present in any channels --- app/assets/javascripts/discourse/app/services/presence.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index 346dcef1af..229963fa18 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -415,9 +415,14 @@ export default class PresenceService extends Service { .filter((e) => e.type === "leave") .map((e) => e.channel); + channelsToLeave.push(...this._presentChannels); + + if (channelsToLeave.length === 0) { + return; + } + const data = new FormData(); data.append("client_id", this.messageBus.clientId); - this._presentChannels.forEach((ch) => data.append("leave_channels[]", ch)); channelsToLeave.forEach((ch) => data.append("leave_channels[]", ch)); data.append("authenticity_token", Session.currentProp("csrfToken")); From d43b77b56b48b74f27c6acab03ac363501368c28 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 16:44:20 +0100 Subject: [PATCH 052/254] DEV: Ensure presence beforeunload event listener is cleaned up In production, services are singletons. This logic will only affect the test environment --- .../javascripts/discourse/app/services/presence.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index 229963fa18..da639f150d 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -227,9 +227,14 @@ export default class PresenceService extends Service { this._presentProxies = {}; this._subscribedProxies = {}; this._initialDataRequests = {}; - window.addEventListener("beforeunload", () => { - this._beaconLeaveAll(); - }); + + this._beforeUnloadCallback = () => this._beaconLeaveAll(); + window.addEventListener("beforeunload", this._beforeUnloadCallback); + } + + willDestroy() { + super.willDestroy(...arguments); + window.removeEventListener("beforeunload", this._beforeUnloadCallback); } // Get a PresenceChannel object representing a single channel From edc1378aed64ad2104e3f3b8393f294495b3fbd9 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 12:46:07 +0100 Subject: [PATCH 053/254] DEV: Raise error when anon subscribes to PresenceChannels on login_required sites In this situation, messagebus and the server-side Presence endpoints are unavailable, so it's better to quit early in the JS --- app/assets/javascripts/discourse/app/services/presence.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index da639f150d..d628603095 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -389,6 +389,10 @@ export default class PresenceService extends Service { } async _subscribe(channelProxy, initialData = null) { + if (this.siteSettings.login_required && !this.currentUser) { + throw "Presence is only available to authenticated users on login-required sites"; + } + this._addSubscribed(channelProxy); const channelName = channelProxy.name; let state = this._presenceChannelStates[channelName]; From 495aa4752c63cc22f609b8ac2c9e5dbe6c1f96a1 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 16:43:07 +0100 Subject: [PATCH 054/254] FIX: Correctly avoid concurrent requests in PresenceChannel get This if statement was intended to prevent concurrent `/presence/get` requests, but it was missing a critical line. Now it should function as intended. --- app/assets/javascripts/discourse/app/services/presence.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index d628603095..0ffcacdc0d 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -262,6 +262,7 @@ export default class PresenceService extends Service { if (this._initialDataAjax) { // try again next runloop next(this, () => once(this, this._makeInitialDataRequest)); + return; } if (Object.keys(this._initialDataRequests).length === 0) { From b3f642db65830498d5b5df3a5b2991f5674b7cc0 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 26 Oct 2021 13:43:20 -0400 Subject: [PATCH 055/254] UX: Don't show anchor icon on touch devices (#14724) --- .../stylesheets/common/base/topic-post.scss | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index e3b6b1e2fd..78d6ae829b 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -95,6 +95,8 @@ $quote-share-maxwidth: 150px; margin: 30px 0 10px; line-height: $line-height-medium; a.anchor { + opacity: 0; + transition: opacity 0.25s; &:before { content: svg-uri( '' @@ -103,23 +105,14 @@ $quote-share-maxwidth: 150px; margin-left: -20px; padding-right: 4px; position: absolute; - opacity: 0; - transition: opacity 0.25s; - } - &:hover { - // show when hovering where icon should be - &:before { - opacity: 1; - } } } - &:hover { - // show when hovering header - a.anchor { - &:before { - opacity: 1; - } - } + + // show when hovering where icon should be + // show when hovering header + .discourse-no-touch & a.anchor:hover, + .discourse-no-touch &:hover a.anchor { + opacity: 1; } } From 46d96c9feb15ff83b1e031c239a68ca1e56ad508 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 19:16:52 +0100 Subject: [PATCH 056/254] DEV: Apply rubocop to script/import_scripts/phorum.rb (#14727) Followup to b24002018a10e12b7bdee3a8f5218345716af6ff --- script/import_scripts/phorum.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/script/import_scripts/phorum.rb b/script/import_scripts/phorum.rb index e18d60f85c..dc2639933e 100644 --- a/script/import_scripts/phorum.rb +++ b/script/import_scripts/phorum.rb @@ -25,7 +25,7 @@ class ImportScripts::Phorum < ImportScripts::Base import_users import_categories import_posts - import_attachments + import_attachments end def import_users @@ -209,7 +209,7 @@ class ImportScripts::Phorum < ImportScripts::Base s.gsub!(/\[\/color\]/i, "") s.gsub!(/\[hr\]/i, "
") - + # remove trailing
s = s.chomp("
") @@ -229,12 +229,12 @@ class ImportScripts::Phorum < ImportScripts::Base where message_id > 0 order by file_id SQL - + current_count = 0 total_count = uploads.count uploads.each do |upload| - + # puts "*** processing file #{upload['file_id']}" post_id = post_id_from_imported_post_id(upload['message_id']) @@ -256,8 +256,8 @@ class ImportScripts::Phorum < ImportScripts::Base } upl_obj = create_upload(post.user.id, filename, real_filename) - - # puts "discourse post #{post['id']} and upload #{upl_obj['id']}" + + # puts "discourse post #{post['id']} and upload #{upl_obj['id']}" if upl_obj&.persisted? html = html_for_upload(upl_obj, real_filename) @@ -282,7 +282,7 @@ class ImportScripts::Phorum < ImportScripts::Base print_status(current_count, total_count) end end - + end ImportScripts::Phorum.new.perform From d067ee1c5a73bd71e0d054483f8140f623e946f1 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 26 Oct 2021 14:53:49 -0400 Subject: [PATCH 057/254] UX: Never show back button if less than current position --- app/assets/javascripts/discourse/app/widgets/topic-timeline.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js index f5b67655d5..310a8a346c 100644 --- a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js +++ b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js @@ -225,10 +225,11 @@ createWidget("timeline-scrollarea", { let showButton = false; const hasBackPosition = + position.lastRead && position.lastRead > 3 && + position.lastRead > position.current && Math.abs(position.lastRead - position.current) > 3 && Math.abs(position.lastRead - position.total) > 1 && - position.lastRead && position.lastRead !== position.total; if (hasBackPosition) { From e073451eaed8930da7d7012cfae35ec4db2f7468 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 26 Oct 2021 21:15:20 +0100 Subject: [PATCH 058/254] UX: Make PresenceChannel changes more responsive (#14733) For very fast-paced things (e.g. replying... indicators), 5s resolution is not great. This commit improves the resolution to 1 update per second. --- app/assets/javascripts/discourse/app/services/presence.js | 2 +- app/controllers/presence_controller.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index 0ffcacdc0d..8a53965313 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -18,7 +18,7 @@ import User from "discourse/models/user"; const PRESENCE_INTERVAL_S = 30; const PRESENCE_DEBOUNCE_MS = isTesting() ? 0 : 500; -const PRESENCE_THROTTLE_MS = isTesting() ? 0 : 5000; +const PRESENCE_THROTTLE_MS = isTesting() ? 0 : 1000; const PRESENCE_GET_RETRY_MS = 5000; diff --git a/app/controllers/presence_controller.rb b/app/controllers/presence_controller.rb index fd10fc0429..653250a8af 100644 --- a/app/controllers/presence_controller.rb +++ b/app/controllers/presence_controller.rb @@ -39,8 +39,9 @@ class PresenceController < ApplicationController client_id = params[:client_id] raise Discourse::InvalidParameters.new(:client_id) if !client_id.is_a?(String) || client_id.blank? - # JS client is designed to throttle to one request every 5 seconds - RateLimiter.new(nil, "update-presence-#{current_user.id}-#{client_id}}", 3, 10.seconds).performed! + # JS client is designed to throttle to one request per second + # When no changes are being made, it makes one request every 30 seconds + RateLimiter.new(nil, "update-presence-#{current_user.id}", 20, 10.seconds).performed! present_channels = params[:present_channels] if present_channels && !(present_channels.is_a?(Array) && present_channels.all? { |c| c.is_a? String }) From 31aa071e54df5eae3610db52e93f069379bfd924 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 26 Oct 2021 16:46:02 -0400 Subject: [PATCH 059/254] UX: Apply tags during PM creation (#14731) --- .../discourse/app/controllers/composer.js | 5 +- .../acceptance/user-private-messages-test.js | 130 +++++++++++++++++- app/assets/stylesheets/desktop/user.scss | 5 + 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 8567c4056c..479b70202b 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -192,10 +192,13 @@ export default Controller.extend({ @discourseComputed("model.canEditTitle", "model.creatingPrivateMessage") canEditTags(canEditTitle, creatingPrivateMessage) { + if (creatingPrivateMessage && this.site.mobileView) { + return false; + } + return ( this.site.can_tag_topics && canEditTitle && - !creatingPrivateMessage && (!this.get("model.topic.isPrivateMessage") || this.site.can_tag_pms) ); }, diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js index 8ffcbce54a..7f46ab892b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js @@ -1,8 +1,9 @@ -import { visit } from "@ember/test-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; import { test } from "qunit"; import I18n from "I18n"; import { acceptance, + controllerFor, count, exists, publishToMessageBus, @@ -10,6 +11,7 @@ import { updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { fixturesByUrl } from "discourse/tests/helpers/create-pretender"; +import selectKit from "../helpers/select-kit-helper"; acceptance( "User Private Messages - user with no group messages", @@ -723,3 +725,129 @@ acceptance("User Private Messages - user with no messages", function (needs) { assert.ok(exists("div.empty-state")); }); }); + +acceptance( + "User Private Messages - composer with tags - Desktop", + function (needs) { + needs.user(); + needs.pretender((server, helper) => { + server.post("/posts", () => { + return helper.response({ + action: "create_post", + post: { + id: 323, + name: "Robin Ward", + username: "eviltrout", + avatar_template: + "/letter_avatar_proxy/v4/letter/j/b77776/{size}.png", + created_at: "2021-10-26T11:47:54.253Z", + cooked: "

Testing private messages with tags

", + post_number: 1, + post_type: 1, + updated_at: "2021-10-26T11:47:54.253Z", + yours: true, + topic_id: 161, + topic_slug: "testing-private-messages-with-tags", + raw: "This is a test for private messages with tags", + user_id: 29, + }, + success: true, + }); + }); + + server.get("/t/161.json", () => { + return helper.response(200, {}); + }); + + server.get("/u/search/users", () => { + return helper.response({ + users: [ + { + username: "eviltrout", + name: "Robin Ward", + avatar_template: + "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png", + }, + { + username: "r_ocelot", + name: "Revolver Ocelot", + avatar_template: + "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png", + }, + ], + }); + }); + }); + + needs.site({ + can_tag_pms: true, + can_tag_topics: true, + }); + + test("tags are present on private messages - Desktop mode", async function (assert) { + const controller = controllerFor("user"); + controller.set("publicUserFieldsLinkified", []); + + await visit("/u/eviltrout/messages"); + await click(".new-private-message"); + + assert.ok(exists("#reply-control .mini-tag-chooser")); + + await fillIn("#reply-title", "Sending a message with tags"); + await fillIn( + "#reply-control .d-editor-input", + "This is a message to test tags" + ); + + const users = selectKit("#reply-control .user-chooser"); + + await users.expand(); + await fillIn( + "#private-message-users-body input.filter-input", + "eviltrout" + ); + await users.selectRowByValue("eviltrout"); + + await fillIn( + "#private-message-users-body input.filter-input", + "r_ocelot" + ); + await users.selectRowByValue("r_ocelot"); + + const tags = selectKit("#reply-control .mini-tag-chooser"); + await tags.expand(); + await tags.selectRowByValue("monkey"); + await tags.selectRowByValue("gazelle"); + + await click("#reply-control .save-or-cancel button"); + + assert.equal( + currentURL(), + "/t/testing-private-messages-with-tags/161", + "it creates the private message" + ); + }); + } +); + +acceptance( + "User Private Messages - composer with tags - Mobile", + function (needs) { + needs.mobileView(); + needs.user(); + + needs.site({ + can_tag_pms: true, + can_tag_topics: true, + }); + + test("tags are not present on private messages - Mobile mode", async function (assert) { + const controller = controllerFor("user"); + controller.set("publicUserFieldsLinkified", []); + + await visit("/u/eviltrout/messages"); + await click(".new-private-message"); + assert.ok(!exists("#reply-control .mini-tag-chooser")); + }); + } +); diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 94274b24c8..09bdd5ed72 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -272,6 +272,11 @@ table.user-invite-list { .show-mores { position: absolute; } + + #reply-control .mini-tag-chooser { + width: 100%; + margin: 0; + } } .user-messages { From c7768b6d169e413e9343626adb5d688dee3fd568 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 26 Oct 2021 22:46:53 +0200 Subject: [PATCH 060/254] FIX: Avoid N+1 query in `Site.json_for` (#14729) --- app/models/site.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index 4dbc2ea402..b1d3dcaa5d 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -138,12 +138,11 @@ class Site end def self.json_for(guardian) - if guardian.anonymous? && SiteSetting.login_required return { periods: TopTopic.periods.map(&:to_s), filters: Discourse.filters.map(&:to_s), - user_fields: UserField.all.map do |userfield| + user_fields: UserField.includes(:user_field_options).all.map do |userfield| UserFieldSerializer.new(userfield, root: false, scope: guardian) end, auth_providers: Discourse.enabled_auth_providers.map do |provider| From 50f4f438257fb6761172201a9f0a4af80b6e7dd8 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 01:09:30 +0100 Subject: [PATCH 061/254] DEV: Use double quotes for js-flags (#14693) Single quotes seem to be raising parse errors in some CI situations. Switching to double quotes seems to fix the problem. --- app/assets/javascripts/discourse/testem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/testem.js b/app/assets/javascripts/discourse/testem.js index 50b41a2852..7cf254e0c6 100644 --- a/app/assets/javascripts/discourse/testem.js +++ b/app/assets/javascripts/discourse/testem.js @@ -41,7 +41,7 @@ module.exports = { "--remote-debugging-port=4201", "--window-size=1440,900", "--enable-precise-memory-info", - "--js-flags='--max_old_space_size=512 --max_semi_space_size=512'", + '--js-flags="--max_old_space_size=512 --max_semi_space_size=512"', ].filter(Boolean), Firefox: ["-headless", "--width=1440", "--height=900"], "Headless Firefox": ["--width=1440", "--height=900"], From 6aa6275f3f70c024d03e0ecff46c5562fe51eb13 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 26 Oct 2021 20:46:53 -0400 Subject: [PATCH 062/254] UX: change tabindex on login to skip email login link (#14732) --- .../app/templates/components/login-buttons.hbs | 2 +- .../discourse/app/templates/modal/login.hbs | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs index c4e553f5b1..662b3b3867 100644 --- a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs @@ -1,5 +1,5 @@ {{#each buttons as |b|}} -
@@ -67,7 +67,7 @@ id="new-account-link" action=(action "createAccount") label="create_account.title" - tabindex="2"}} + tabindex="3"}} {{/if}} {{/if}} {{conditional-loading-spinner condition=showSpinner size="small"}} From 79ad0860a253def65536179e6e8c925db017faef Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 15:17:10 +0100 Subject: [PATCH 070/254] FIX: Cleanup PresenceChannel instances when composer closed (#14741) I was previously relying on `this.isDestroying` returning `true` during `willDestroyElement`. This was an incorrect assumption. This commit refactors the logic into an explicit `cleanup` function, and also adds some cleanup for empty keys in the `subscribedProxy` array --- .../discourse/app/services/presence.js | 3 +++ .../components/composer-presence-display.js.es6 | 15 ++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index 8a53965313..6108e98782 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -331,6 +331,9 @@ export default class PresenceService extends Service { _removeSubscribed(channelProxy) { let subscribed = this._subscribedProxies[channelProxy.name]; subscribed?.delete(channelProxy); + if (subscribed?.size === 0) { + delete this._subscribedProxies[channelProxy.name]; + } return subscribed?.size || 0; } diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 index 6e3343800c..4430cf35ae 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 @@ -14,11 +14,10 @@ export default Component.extend({ "model.replyingToTopic", "model.editingPost", "model.whisper", - "model.composerOpened", - "isDestroying" + "model.composerOpened" ) - state(replyingToTopic, editingPost, whisper, composerOpen, isDestroying) { - if (!composerOpen || isDestroying) { + state(replyingToTopic, editingPost, whisper, composerOpen) { + if (!composerOpen) { return; } else if (editingPost) { return "edit"; @@ -73,6 +72,12 @@ export default Component.extend({ this._setupChannel("editChannel", this.editChannelName); }, + _cleanupChannels() { + this._setupChannel("replyChannel", null); + this._setupChannel("whisperChannel", null); + this._setupChannel("editChannel", null); + }, + replyingUsers: union("replyChannel.users", "whisperChannel.users"), editingUsers: readOnly("editChannel.users"), @@ -102,7 +107,7 @@ export default Component.extend({ @on("willDestroyElement") closeComposer() { - this._setupChannels(); + this._cleanupChannels(); this.composerPresenceManager.leave(); }, }); From 184ccf449013d96e2897f95083efe514e187694a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 27 Oct 2021 16:29:36 +0200 Subject: [PATCH 071/254] DEV: nullify cachedCookFunction to prevent retains (#14737) --- app/assets/javascripts/discourse/app/components/d-editor.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 312c0719a1..cc342043ee 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -341,6 +341,8 @@ export default Component.extend(TextareaTextManipulation, { if (isTesting()) { this.element.removeEventListener("paste", this.paste); } + + this._cachedCookFunction = null; }, @discourseComputed() From df3eb939732f86edc143445f267d1dcdd2e940ff Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Wed, 27 Oct 2021 11:33:07 -0300 Subject: [PATCH 072/254] DEV: Sanitize HTML admin inputs (#14681) * DEV: Sanitize HTML admin inputs This PR adds on-save HTML sanitization for: Client site settings translation overrides badges descriptions user fields descriptions I used Rails's SafeListSanitizer, which [accepts the following HTML tags and attributes](https://github.com/rails/rails-html-sanitizer/blob/018cf540737ae10ee1c673ce184408881852c479/lib/rails/html/sanitizer.rb#L108) * Make sure that the sanitization logic doesn't corrupt settings with special characters --- app/models/badge.rb | 8 +++++++ app/models/concerns/has_sanitizable_fields.rb | 23 +++++++++++++++++++ app/models/translation_override.rb | 10 ++++---- app/models/user_field.rb | 10 ++++++++ config/locales/client.en.yml | 8 +++---- lib/site_setting_extension.rb | 9 ++++++-- spec/models/badge_spec.rb | 9 ++++++++ spec/models/site_setting_spec.rb | 18 +++++++++++++++ spec/models/translation_override_spec.rb | 10 ++++++++ spec/models/user_field_spec.rb | 9 ++++++++ 10 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 app/models/concerns/has_sanitizable_fields.rb diff --git a/app/models/badge.rb b/app/models/badge.rb index 5e480aa2e5..af0c054d66 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -5,6 +5,7 @@ class Badge < ActiveRecord::Base self.ignored_columns = %w{image} include GlobalPath + include HasSanitizableFields # NOTE: These badge ids are not in order! They are grouped logically. # When picking an id, *search* for it. @@ -116,6 +117,7 @@ class Badge < ActiveRecord::Base scope :enabled, -> { where(enabled: true) } before_create :ensure_not_system + before_save :sanitize_description after_commit do SvgSprite.expire_cache @@ -314,6 +316,12 @@ class Badge < ActiveRecord::Base def ensure_not_system self.id = [Badge.maximum(:id) + 1, 100].max unless id end + + def sanitize_description + if description_changed? + self.description = sanitize_field(self.description) + end + end end # == Schema Information diff --git a/app/models/concerns/has_sanitizable_fields.rb b/app/models/concerns/has_sanitizable_fields.rb new file mode 100644 index 0000000000..b0db07de00 --- /dev/null +++ b/app/models/concerns/has_sanitizable_fields.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module HasSanitizableFields + extend ActiveSupport::Concern + + def sanitize_field(field, additional_attributes: []) + if field + sanitizer = Rails::Html::SafeListSanitizer.new + allowed_attributes = Rails::Html::SafeListSanitizer.allowed_attributes + + if additional_attributes.present? + allowed_attributes = allowed_attributes.merge(additional_attributes) + end + + field = CGI.unescape_html(sanitizer.sanitize(field, attributes: allowed_attributes)) + # Just replace the characters that our translations use for interpolation. + # Calling CGI.unescape removes characters like '+', which will corrupt the original value. + field = field.gsub('%7B', '{').gsub('%7D', '}') + end + + field + end +end diff --git a/app/models/translation_override.rb b/app/models/translation_override.rb index edd2b85084..85b09c0e74 100644 --- a/app/models/translation_override.rb +++ b/app/models/translation_override.rb @@ -39,6 +39,7 @@ class TranslationOverride < ActiveRecord::Base } } + include HasSanitizableFields include ActiveSupport::Deprecation::DeprecatedConstantAccessor deprecate_constant 'CUSTOM_INTERPOLATION_KEYS_WHITELIST', 'TranslationOverride::ALLOWED_CUSTOM_INTERPOLATION_KEYS' @@ -50,13 +51,15 @@ class TranslationOverride < ActiveRecord::Base def self.upsert!(locale, key, value) params = { locale: locale, translation_key: key } - data = { value: value } + translation_override = find_or_initialize_by(params) + sanitized_value = translation_override.sanitize_field(value, additional_attributes: ['data-auto-route']) + + data = { value: sanitized_value } if key.end_with?('_MF') _, filename = JsLocaleHelper.find_message_format_locale([locale], fallback_to_english: false) - data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, value) + data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, sanitized_value) end - translation_override = find_or_initialize_by(params) params.merge!(data) if translation_override.new_record? i18n_changed(locale, [key]) if translation_override.update(data) translation_override @@ -125,7 +128,6 @@ class TranslationOverride < ActiveRecord::Base if original_text original_interpolation_keys = I18nInterpolationKeysFinder.find(original_text) new_interpolation_keys = I18nInterpolationKeysFinder.find(value) - custom_interpolation_keys = [] ALLOWED_CUSTOM_INTERPOLATION_KEYS.select do |keys, value| diff --git a/app/models/user_field.rb b/app/models/user_field.rb index b024574894..e5c3caf86d 100644 --- a/app/models/user_field.rb +++ b/app/models/user_field.rb @@ -3,6 +3,7 @@ class UserField < ActiveRecord::Base include AnonCacheInvalidator + include HasSanitizableFields validates_presence_of :description, :field_type validates_presence_of :name, unless: -> { field_type == "confirm" } @@ -10,6 +11,7 @@ class UserField < ActiveRecord::Base has_one :directory_column, dependent: :destroy accepts_nested_attributes_for :user_field_options + before_save :sanitize_description after_save :queue_index_search def self.max_length @@ -19,6 +21,14 @@ class UserField < ActiveRecord::Base def queue_index_search SearchIndexer.queue_users_reindex(UserCustomField.where(name: "user_field_#{self.id}").pluck(:user_id)) end + + private + + def sanitize_description + if description_changed? + self.description = sanitize_field(self.description) + end + end end # == Schema Information diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 67b081f45c..58e2adf1e3 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4555,11 +4555,11 @@ en: Prefixing the property names is highly recommended to avoid conflicts with plugins and/or core. head_tag: - text: "" - title: "HTML that will be inserted before the tag" + text: "Head" + title: "HTML that will be inserted before the head tag" body_tag: - text: "" - title: "HTML that will be inserted before the tag" + text: "Body" + title: "HTML that will be inserted before the body tag" yaml: text: "YAML" title: "Define theme settings in YAML format" diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index dd529e8e71..c86c67ff24 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -2,6 +2,7 @@ module SiteSettingExtension include SiteSettings::DeprecatedSettings + include HasSanitizableFields # support default_locale being set via global settings # this also adds support for testing the extension and global settings @@ -362,8 +363,12 @@ module SiteSettingExtension def add_override!(name, val) old_val = current[name] val, type = type_supervisor.to_db_value(name, val) - provider.save(name, val, type) - current[name] = type_supervisor.to_rb_value(name, val) + + sanitize_override = val.is_a?(String) && client_settings.include?(name) + + sanitized_val = sanitize_override ? sanitize_field(val) : val + provider.save(name, sanitized_val, type) + current[name] = type_supervisor.to_rb_value(name, sanitized_val) clear_uploads_cache(name) notify_clients!(name) if client_settings.include? name clear_cache! diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb index 55cf3b5148..2125292364 100644 --- a/spec/models/badge_spec.rb +++ b/spec/models/badge_spec.rb @@ -51,6 +51,15 @@ describe Badge do expect(b.grant_count).to eq(1) end + it 'sanitizes the description' do + xss = "click me!" + badge = Fabricate(:badge) + + badge.update!(description: xss) + + expect(badge.description).to eq("click me!alert('TEST');") + end + describe '#manually_grantable?' do fab!(:badge) { Fabricate(:badge, name: 'Test Badge') } subject { badge.manually_grantable? } diff --git a/spec/models/site_setting_spec.rb b/spec/models/site_setting_spec.rb index 7a81584878..7d858a951d 100644 --- a/spec/models/site_setting_spec.rb +++ b/spec/models/site_setting_spec.rb @@ -204,4 +204,22 @@ describe SiteSetting do expect(SiteSetting.blocked_attachment_filenames_regex).to eq(/foo|bar/) end end + + it 'sanitizes the client settings when they are overridden' do + xss = "click me!" + + SiteSetting.global_notice = xss + + expect(SiteSetting.global_notice).to eq("click me!alert('TEST');") + end + + it "doesn't corrupt site settings with special characters" do + value = 'OX5y3Oljb+Qt9Bu809vsBQ==<>!%{}*&!@#$%..._-A' + settings = new_settings(SiteSettings::LocalProcessProvider.new) + settings.setting(:test_setting, '', client: true) + + settings.test_setting = value + + expect(settings.test_setting).to eq(value) + end end diff --git a/spec/models/translation_override_spec.rb b/spec/models/translation_override_spec.rb index dddb037ba1..9a05f30c28 100644 --- a/spec/models/translation_override_spec.rb +++ b/spec/models/translation_override_spec.rb @@ -115,6 +115,16 @@ describe TranslationOverride do expect(ovr.value).to eq('some value') end + it 'sanitizes values before upsert' do + xss = "setup wizard" + + TranslationOverride.upsert!('en', 'js.wizard_required', xss) + + ovr = TranslationOverride.where(locale: 'en', translation_key: 'js.wizard_required').first + expect(ovr).to be_present + expect(ovr.value).to eq("setup wizard ✨alert('TEST');") + end + it "stores js for a message format key" do TranslationOverride.upsert!('ru', 'some.key_MF', '{NUM_RESULTS, plural, one {1 result} other {many} }') diff --git a/spec/models/user_field_spec.rb b/spec/models/user_field_spec.rb index 7f545b3706..e738591146 100644 --- a/spec/models/user_field_spec.rb +++ b/spec/models/user_field_spec.rb @@ -12,4 +12,13 @@ describe UserField do subject { described_class.new(field_type: 'dropdown') } it { is_expected.to validate_presence_of :name } end + + it 'sanitizes the description' do + xss = "click me!" + user_field = Fabricate(:user_field) + + user_field.update!(description: xss) + + expect(user_field.description).to eq("click me!alert('TEST');") + end end From d2ddb82022ed924d86447b749300078cb4ae107f Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 27 Oct 2021 13:09:34 -0400 Subject: [PATCH 073/254] UX: Remove animation in admin theme list (#14743) --- .../addon/components/themes-list-item.js | 82 +------------------ .../stylesheets/common/admin/customize.scss | 9 +- 2 files changed, 6 insertions(+), 85 deletions(-) diff --git a/app/assets/javascripts/admin/addon/components/themes-list-item.js b/app/assets/javascripts/admin/addon/components/themes-list-item.js index edc1783535..b619f160b4 100644 --- a/app/assets/javascripts/admin/addon/components/themes-list-item.js +++ b/app/assets/javascripts/admin/addon/components/themes-list-item.js @@ -1,10 +1,8 @@ import { and, gt } from "@ember/object/computed"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { escape } from "pretty-text/sanitizer"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { isTesting } from "discourse-common/config/environment"; -import { schedule } from "@ember/runloop"; const MAX_COMPONENTS = 4; @@ -22,36 +20,6 @@ export default Component.extend({ } }, - init() { - this._super(...arguments); - this.scheduleAnimation(); - }, - - @observes("theme.selected") - triggerAnimation() { - this.animate(); - }, - - scheduleAnimation() { - schedule("afterRender", () => { - this.animate(true); - }); - }, - - animate(isInitial) { - const $container = $(this.element); - const $list = $(this.element.querySelector(".components-list")); - if ($list.length === 0 || isTesting()) { - return; - } - const duration = 300; - if (this.get("theme.selected")) { - this.collapseComponentsList($container, $list, duration); - } else if (!isInitial) { - this.expandComponentsList($container, $list, duration); - } - }, - @discourseComputed( "theme.component", "theme.childThemes.@each.name", @@ -91,54 +59,6 @@ export default Component.extend({ return childrenCount - MAX_COMPONENTS; }, - expandComponentsList($container, $list, duration) { - $container.css("height", `${$container.height()}px`); - $list.css("display", ""); - $container.animate( - { - height: `${$container.height() + $list.outerHeight(true)}px`, - }, - { - duration, - done: () => { - $list.css("display", ""); - $container.css("height", ""); - }, - } - ); - $list.animate( - { - opacity: 1, - }, - { - duration, - } - ); - }, - - collapseComponentsList($container, $list, duration) { - $container.animate( - { - height: `${$container.height() - $list.outerHeight(true)}px`, - }, - { - duration, - done: () => { - $list.css("display", "none"); - $container.css("height", ""); - }, - } - ); - $list.animate( - { - opacity: 0, - }, - { - duration, - } - ); - }, - actions: { toggleChildrenExpanded() { this.toggleProperty("childrenExpanded"); diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index f7a9f7721c..8b3811ec90 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -244,9 +244,6 @@ .components-list { color: var(--secondary); } - .fa { - color: inherit; - } } &:not(.selected) { .broken-indicator { @@ -284,9 +281,13 @@ .others-count, .others-count:hover { - color: var(--primary-high); + color: inherit; text-decoration: underline; } + + .d-icon { + color: inherit; + } } .inner-wrapper { From 0bec323204ab8c2d7dd38c0469c7c39da72d0ab8 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 16:25:00 +0100 Subject: [PATCH 074/254] DEV: Teardown leaky page:changed `appEvent`s between tests --- .../app/initializers/opengraph-tag-updater.js | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/app/initializers/opengraph-tag-updater.js b/app/assets/javascripts/discourse/app/initializers/opengraph-tag-updater.js index 66ab52a817..1d8eba4ae4 100644 --- a/app/assets/javascripts/discourse/app/initializers/opengraph-tag-updater.js +++ b/app/assets/javascripts/discourse/app/initializers/opengraph-tag-updater.js @@ -6,15 +6,21 @@ export default { initialize(container) { // workaround for Safari on iOS 14.3 // seems it has started using opengraph tags when sharing - let appEvents = container.lookup("service:app-events"); - const ogTitle = document.querySelector("meta[property='og:title']"), - ogUrl = document.querySelector("meta[property='og:url']"); + this.appEvents = container.lookup("service:app-events"); + this.ogTitle = document.querySelector("meta[property='og:title']"); + this.ogUrl = document.querySelector("meta[property='og:url']"); - if (ogTitle && ogUrl) { - appEvents.on("page:changed", (data) => { - ogTitle.setAttribute("content", data.title); - ogUrl.setAttribute("content", getAbsoluteURL(data.url)); - }); + if (this.ogTitle && this.ogUrl) { + this.appEvents.on("page:changed", this, this.updateOgAttributes); } }, + + updateOgAttributes(data) { + this.ogTitle.setAttribute("content", data.title); + this.ogUrl.setAttribute("content", getAbsoluteURL(data.url)); + }, + + teardown() { + this.appEvents.off("page:changed", this, this.updateOgAttributes); + }, }; From cc18a5439ce58f5e9b53f3968ffe691d66332766 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 16:23:48 +0100 Subject: [PATCH 075/254] DEV: Ensure core initializers with `.reopen` are only called once --- .../discourse/app/initializers/ember-events.js | 10 +++++++++- .../initializers/ember-input-component-extension.js | 7 +++++++ .../app/initializers/ember-link-component-extension.js | 8 ++++++++ .../discourse/app/initializers/logs-notice.js | 7 +++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/initializers/ember-events.js b/app/assets/javascripts/discourse/app/initializers/ember-events.js index 98c2a241a3..a359c0bb06 100644 --- a/app/assets/javascripts/discourse/app/initializers/ember-events.js +++ b/app/assets/javascripts/discourse/app/initializers/ember-events.js @@ -1,9 +1,15 @@ +let initializedOnce = false; + export default { name: "ember-events", initialize: function () { // By default Ember listens to too many events. This tells it the only events - // we're interested in. + // we're interested in. (it removes mousemove and touchmove) + if (initializedOnce) { + return; + } + Ember.EventDispatcher.reopen({ events: { touchstart: "touchStart", @@ -33,5 +39,7 @@ export default { dragend: "dragEnd", }, }); + + initializedOnce = true; }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js index 902f7958c6..841ff0e992 100644 --- a/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js +++ b/app/assets/javascripts/discourse/app/initializers/ember-input-component-extension.js @@ -1,15 +1,22 @@ import TextField from "@ember/component/text-field"; import TextArea from "@ember/component/text-area"; +let initializedOnce = false; export default { name: "ember-input-component-extensions", initialize() { + if (initializedOnce) { + return; + } + TextField.reopen({ attributeBindings: ["aria-describedby", "aria-invalid"], }); TextArea.reopen({ attributeBindings: ["aria-describedby", "aria-invalid"], }); + + initializedOnce = true; }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/ember-link-component-extension.js b/app/assets/javascripts/discourse/app/initializers/ember-link-component-extension.js index 807b58e39f..33accacf11 100644 --- a/app/assets/javascripts/discourse/app/initializers/ember-link-component-extension.js +++ b/app/assets/javascripts/discourse/app/initializers/ember-link-component-extension.js @@ -1,9 +1,17 @@ +let initializedOnce = false; + export default { name: "ember-link-component-extensions", initialize() { + if (initializedOnce) { + return; + } + Ember.LinkComponent.reopen({ attributeBindings: ["name"], }); + + initializedOnce = true; }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/logs-notice.js b/app/assets/javascripts/discourse/app/initializers/logs-notice.js index 08867d129d..164f507448 100644 --- a/app/assets/javascripts/discourse/app/initializers/logs-notice.js +++ b/app/assets/javascripts/discourse/app/initializers/logs-notice.js @@ -1,11 +1,16 @@ import LogsNotice from "discourse/services/logs-notice"; import Singleton from "discourse/mixins/singleton"; +let initializedOnce = false; export default { name: "logs-notice", after: "message-bus", initialize: function (container) { + if (initializedOnce) { + return; + } + const siteSettings = container.lookup("site-settings:main"); const messageBus = container.lookup("message-bus:main"); const keyValueStore = container.lookup("key-value-store:main"); @@ -20,5 +25,7 @@ export default { }); }, }); + + initializedOnce = true; }, }; From 8af0674b68c0369681e770440f0d5d3e8207c230 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 16:53:59 +0100 Subject: [PATCH 076/254] DEV: teardown event listeners in click-interceptor --- .../discourse/app/initializers/click-interceptor.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/initializers/click-interceptor.js b/app/assets/javascripts/discourse/app/initializers/click-interceptor.js index 9f8adfd7a7..35eaa87be1 100644 --- a/app/assets/javascripts/discourse/app/initializers/click-interceptor.js +++ b/app/assets/javascripts/discourse/app/initializers/click-interceptor.js @@ -5,8 +5,15 @@ export default { name: "click-interceptor", initialize() { $("#main").on("click.discourse", "a", interceptClick); - $(window).on("hashchange", () => - DiscourseURL.routeTo(document.location.hash) - ); + window.addEventListener("hashchange", this.hashChanged); + }, + + hashChanged() { + DiscourseURL.routeTo(document.location.hash); + }, + + teardown() { + $("#main").off("click.discourse", "a", interceptClick); + window.removeEventListener("hashchange", this.hashChanged); }, }; From 8bb911e038cc626016a6cf0e988d7608ab7ea5d3 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 17:49:23 +0100 Subject: [PATCH 077/254] DEV: Teardown appEvent listener in edit-notification-clicks-tracker --- .../edit-notification-clicks-tracker.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/app/initializers/edit-notification-clicks-tracker.js b/app/assets/javascripts/discourse/app/initializers/edit-notification-clicks-tracker.js index c9e29142ac..8146c40218 100644 --- a/app/assets/javascripts/discourse/app/initializers/edit-notification-clicks-tracker.js +++ b/app/assets/javascripts/discourse/app/initializers/edit-notification-clicks-tracker.js @@ -4,13 +4,15 @@ export default { name: "edit-notification-clicks-tracker", initialize(container) { - container - .lookup("service:app-events") - .on( - "edit-notification:clicked", - ({ topicId, postNumber, revisionNumber }) => { - setLastEditNotificationClick(topicId, postNumber, revisionNumber); - } - ); + this.appEvents = container.lookup("service:app-events"); + this.appEvents.on("edit-notification:clicked", this, this.handleClick); + }, + + handleClick({ topicId, postNumber, revisionNumber }) { + setLastEditNotificationClick(topicId, postNumber, revisionNumber); + }, + + teardown() { + this.appEvents.off("edit-notification:clicked", this, this.handleClick); }, }; From 9fa5077805a10053548a4b3a7ccd12e3bea3b17f Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 27 Oct 2021 17:24:40 +0100 Subject: [PATCH 078/254] DEV: Remove unneeded qunit-helper override This was leaking on every test... and also doesn't seem to be required. The `examineDockHeader` doesn't exist any more in core, so this was a no-op. --- .../javascripts/discourse/tests/helpers/qunit-helpers.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 115de3833c..cec2ce579a 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -14,7 +14,6 @@ import { getApplication, getContext, settled } from "@ember/test-helpers"; import { getOwner, setDefaultOwner } from "discourse-common/lib/get-owner"; import { later, run } from "@ember/runloop"; import { moduleFor, setupApplicationTest } from "ember-qunit"; -import HeaderComponent from "discourse/components/site-header"; import { Promise } from "rsvp"; import Site from "discourse/models/site"; import User from "discourse/models/user"; @@ -213,9 +212,6 @@ export function acceptance(name, optionsOrCallback) { beforeEach() { resetMobile(); - // For now don't do scrolling stuff in Test Mode - HeaderComponent.reopen({ examineDockHeader: function () {} }); - resetExtraClasses(); if (mobileView) { forceMobile(); From fd187f2de33794e696c95210fdc5d1fb413c8337 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 27 Oct 2021 15:35:23 -0400 Subject: [PATCH 079/254] PERF: There was a large performance regression in Logster I'm not sure what other environments it shows up in, but I discovered it on Apple Silicon. It might be useful in other environments, too. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a283ea8e1f..2b86702218 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -215,7 +215,7 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.9.7) + logster (2.9.8) loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) From ab64022d2586496416d91986eceb9ba85b7154e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Oct 2021 22:29:07 +0200 Subject: [PATCH 080/254] Build(deps): Bump excon from 0.87.0 to 0.88.0 (#14751) Bumps [excon](https://github.com/excon/excon) from 0.87.0 to 0.88.0. - [Release notes](https://github.com/excon/excon/releases) - [Changelog](https://github.com/excon/excon/blob/master/changelog.txt) - [Commits](https://github.com/excon/excon/compare/v0.87.0...v0.88.0) --- updated-dependencies: - dependency-name: excon dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2b86702218..ea69a2b2ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,7 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.87.0) + excon (0.88.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) From 5a851dec0ef343bb2edf96355b5dbd91533ff1b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Oct 2021 22:29:16 +0200 Subject: [PATCH 081/254] Build(deps): Bump rubocop from 1.22.2 to 1.22.3 (#14753) Bumps [rubocop](https://github.com/rubocop/rubocop) from 1.22.2 to 1.22.3. - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.22.2...v1.22.3) --- updated-dependencies: - dependency-name: rubocop dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index ea69a2b2ad..53803ed4d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -389,7 +389,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.22.2) + rubocop (1.22.3) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) From 92f4cdd3309d099e9a4f70a5016d138f2c07578e Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 27 Oct 2021 17:05:10 -0400 Subject: [PATCH 082/254] FEATURE: bypass topic bump when disable_category_edit_notifications is enabled (#14754) --- app/controllers/topics_controller.rb | 5 +++- spec/requests/topics_controller_spec.rb | 31 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 099cc16c75..c2d98ae5d0 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -387,8 +387,11 @@ class TopicsController < ApplicationController success = true if changes.length > 0 + + bypass_bump = changes[:category_id].present? && SiteSetting.disable_category_edit_notifications + first_post = topic.ordered_posts.first - success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false) + success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false, bypass_bump: bypass_bump) if !success && topic.errors.blank? topic.errors.add(:base, :unable_to_update) diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 2aa3b858b2..0171366710 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1358,6 +1358,37 @@ RSpec.describe TopicsController do expect(response.status).to eq(200) end + context 'when using SiteSetting.disable_category_edit_notifications' do + it "doesn't bump the topic if the setting is enabled" do + SiteSetting.disable_category_edit_notifications = true + last_bumped_at = topic.bumped_at + expect(last_bumped_at).not_to be_nil + + expect do + put "/t/#{topic.slug}/#{topic.id}.json", params: { + category_id: category.id + } + end.to change { topic.reload.category_id }.to(category.id) + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).to eq_time(last_bumped_at) + end + + it "bumps the topic if the setting is disabled" do + last_bumped_at = topic.bumped_at + expect(last_bumped_at).not_to be_nil + + expect do + put "/t/#{topic.slug}/#{topic.id}.json", params: { + category_id: category.id + } + end.to change { topic.reload.category_id }.to(category.id) + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).not_to eq_time(last_bumped_at) + end + end + describe "when first post is locked" do it "blocks non-staff from editing even if 'trusted_users_can_edit_others' is true" do SiteSetting.trusted_users_can_edit_others = true From b659e94a8eb444094839df633a1a189423e42eb3 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 28 Oct 2021 07:53:21 +1000 Subject: [PATCH 083/254] DEV: Delete vacate_legacy_prefix_backups code (#14735) Introduced in 3037617327147602d84e584c56a7d69dd001da0a, we no longer need this code, as all of the backups have been migrated. --- .../onceoff/vacate_legacy_prefix_backups.rb | 10 ---- lib/backup_restore/s3_backup_store.rb | 24 --------- .../jobs/vacate_legacy_prefix_backups_spec.rb | 52 ------------------- 3 files changed, 86 deletions(-) delete mode 100644 app/jobs/onceoff/vacate_legacy_prefix_backups.rb delete mode 100644 spec/jobs/vacate_legacy_prefix_backups_spec.rb diff --git a/app/jobs/onceoff/vacate_legacy_prefix_backups.rb b/app/jobs/onceoff/vacate_legacy_prefix_backups.rb deleted file mode 100644 index 0791a01c0d..0000000000 --- a/app/jobs/onceoff/vacate_legacy_prefix_backups.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class VacateLegacyPrefixBackups < ::Jobs::Onceoff - def execute_onceoff(args) - args ||= {} - BackupRestore::S3BackupStore.create(s3_options: args[:s3_options]).vacate_legacy_prefix if SiteSetting.backup_location == BackupLocationSiteSetting::S3 - end - end -end diff --git a/lib/backup_restore/s3_backup_store.rb b/lib/backup_restore/s3_backup_store.rb index f991c05829..71c368405d 100644 --- a/lib/backup_restore/s3_backup_store.rb +++ b/lib/backup_restore/s3_backup_store.rb @@ -51,22 +51,6 @@ module BackupRestore raise StorageError.new(e.message.presence || e.class.name) end - def vacate_legacy_prefix - legacy_s3_helper = S3Helper.new(s3_bucket_name_with_legacy_prefix, '', @s3_options.clone) - bucket, prefix = s3_bucket_name_with_prefix.split('/', 2) - legacy_keys = legacy_s3_helper.list - .reject { |o| o.key.starts_with? prefix } - .map { |o| o.key } - legacy_keys.each do |legacy_key| - @s3_helper.s3_client.copy_object({ - copy_source: File.join(bucket, legacy_key), - bucket: bucket, - key: File.join(prefix, legacy_key.split('/').last) - }) - legacy_s3_helper.delete_object(legacy_key) - end - end - private def unsorted_files @@ -117,14 +101,6 @@ module BackupRestore File.join(SiteSetting.s3_backup_bucket, RailsMultisite::ConnectionManagement.current_db) end - def s3_bucket_name_with_legacy_prefix - if Rails.configuration.multisite - File.join(SiteSetting.s3_backup_bucket, "backups", RailsMultisite::ConnectionManagement.current_db) - else - SiteSetting.s3_backup_bucket - end - end - def file_regex @file_regex ||= begin path = @s3_helper.s3_bucket_folder_path || "" diff --git a/spec/jobs/vacate_legacy_prefix_backups_spec.rb b/spec/jobs/vacate_legacy_prefix_backups_spec.rb deleted file mode 100644 index 45eb0df9ef..0000000000 --- a/spec/jobs/vacate_legacy_prefix_backups_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require "s3_helper" -require "rails_helper" - -describe Jobs::VacateLegacyPrefixBackups, type: :multisite do - let(:bucket_name) { "backupbucket" } - - before do - @s3_client = Aws::S3::Client.new(stub_responses: true) - @s3_options = { client: @s3_client } - @objects = [] - create_backups - - @s3_client.stub_responses(:list_objects_v2, -> (context) do - { contents: objects_with_prefix(context) } - end) - - setup_s3 - SiteSetting.s3_backup_bucket = bucket_name - SiteSetting.backup_location = BackupLocationSiteSetting::S3 - end - - it "copies the backups from legacy path to new path" do - @objects.each do |object| - legacy_key = object[:key] - legacy_object = @s3_client.get_object(bucket: bucket_name, key: legacy_key) - - @s3_client.expects(:copy_object).with({ - copy_source: File.join(bucket_name, legacy_key), - bucket: bucket_name, - key: legacy_key.sub(/^backups\//, "") - }) - - @s3_client.expects(:delete_object).with(bucket: bucket_name, key: legacy_key).returns(legacy_object) - end - - described_class.new.execute_onceoff(s3_options: @s3_options) - end - - def objects_with_prefix(context) - prefix = context.params[:prefix] - @objects.select { |obj| obj[:key].start_with?(prefix) } - end - - def create_backups - @objects.clear - - @objects << { key: "backups/default/b.tar.gz", size: 17, last_modified: Time.parse("2018-09-13T15:10:00Z") } - @objects << { key: "backups/default/filename.tar.gz", size: 17, last_modified: Time.parse("2019-10-18T17:20:00Z") } - end -end From a2851b5d4cdfcae85cc61ac713cb4a54ef2574db Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 28 Oct 2021 11:59:46 +0530 Subject: [PATCH 084/254] FIX: include new tags in validation if user can create one. (#14744) Previously, users who have enough trust level are unable to create topics with new tags if the selected category required a minimum number of tags. --- lib/topic_creator.rb | 8 +++++--- spec/components/topic_creator_spec.rb | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index a250ef69fb..1211c84fe7 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -26,11 +26,13 @@ class TopicCreator category = find_category if category.present? && guardian.can_tag?(topic) - tags = @opts[:tags].present? ? Tag.where(name: @opts[:tags]) : (@opts[:tags] || []) + tags = @opts[:tags].presence || [] + existing_tags = tags.present? ? Tag.where(name: tags) : [] + valid_tags = guardian.can_create_tag? ? tags : existing_tags # both add to topic.errors - DiscourseTagging.validate_min_required_tags_for_category(guardian, topic, category, tags) - DiscourseTagging.validate_required_tags_from_group(guardian, topic, category, tags) + DiscourseTagging.validate_min_required_tags_for_category(guardian, topic, category, valid_tags) + DiscourseTagging.validate_required_tags_from_group(guardian, topic, category, existing_tags) end DiscourseEvent.trigger(:after_validate_topic, topic, self) diff --git a/spec/components/topic_creator_spec.rb b/spec/components/topic_creator_spec.rb index a624e7d5ef..433462a4c2 100644 --- a/spec/components/topic_creator_spec.rb +++ b/spec/components/topic_creator_spec.rb @@ -140,6 +140,12 @@ describe TopicCreator do expect(topic.tags.length).to eq(2) end + it "minimum_required_tags is satisfying for new tags if user can create" do + topic = TopicCreator.create(user, Guardian.new(user), valid_attrs.merge(tags: ["new tag", "another tag"], category: category.id)) + expect(topic).to be_valid + expect(topic.tags.length).to eq(2) + end + it "lets new user create a topic if they don't have sufficient trust level to tag topics" do SiteSetting.min_trust_level_to_tag_topics = 1 new_user = Fabricate(:newuser) From ba3078e0980d82cee0064a18c51407dcc4202b25 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Thu, 28 Oct 2021 11:30:30 +0300 Subject: [PATCH 085/254] PERF: Use different column for better query plan (#14748) Using topics.id provides a better query plan than posts.topic_id which speeds up search by almost 50%. --- app/models/post.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/post.rb b/app/models/post.rb index 5a8488e2ad..7120db505e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -86,8 +86,8 @@ class Post < ActiveRecord::Base scope :private_posts_for_user, ->(user) do where( - "posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER}) - OR posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})", + "topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER}) + OR topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})", user_id: user.id ) end From 9e19b22f64316b61f78e7f3d5b7af54e54eedf6c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 28 Oct 2021 10:55:22 +0200 Subject: [PATCH 086/254] DEV: prevents Firefox ESR tests to crash on `||=` (#14758) A follow up PR should investigate why `proposal-logical-assignment-operators` is not getting used here (test file?) but this should be enough to get things running. --- .../discourse/tests/helpers/presence-pretender.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/helpers/presence-pretender.js b/app/assets/javascripts/discourse/tests/helpers/presence-pretender.js index 7afc731e5f..991b3ad83d 100644 --- a/app/assets/javascripts/discourse/tests/helpers/presence-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/presence-pretender.js @@ -35,8 +35,10 @@ export default function (helper) { } export function getChannelInfo(name) { - channels[name] ||= { count: 0, users: [], last_message_id: 0 }; - return channels[name]; + return ( + channels[name] || + (channels[name] = { count: 0, users: [], last_message_id: 0 }) + ); } export function joinChannel(name, user) { From efc23b6a8deb86aa6caadfaecd95b32f1581c1e7 Mon Sep 17 00:00:00 2001 From: Discourse Translator Bot Date: Thu, 28 Oct 2021 05:24:55 -0400 Subject: [PATCH 087/254] Update translations (#14757) --- config/locales/client.ar.yml | 6 +----- config/locales/client.be.yml | 4 ++-- config/locales/client.bg.yml | 6 +----- config/locales/client.bs_BA.yml | 6 +----- config/locales/client.ca.yml | 6 +----- config/locales/client.cs.yml | 6 +----- config/locales/client.da.yml | 6 +----- config/locales/client.de.yml | 6 +----- config/locales/client.el.yml | 6 +----- config/locales/client.es.yml | 6 +----- config/locales/client.et.yml | 6 +----- config/locales/client.fa_IR.yml | 6 +----- config/locales/client.fi.yml | 6 +----- config/locales/client.fr.yml | 6 +----- config/locales/client.gl.yml | 6 +----- config/locales/client.he.yml | 18 ++++++++-------- config/locales/client.hu.yml | 38 ++++++++++++++++++++++++++++++--- config/locales/client.hy.yml | 6 +----- config/locales/client.id.yml | 2 ++ config/locales/client.it.yml | 8 +++---- config/locales/client.ja.yml | 6 +----- config/locales/client.ko.yml | 6 +----- config/locales/client.lt.yml | 6 +----- config/locales/client.lv.yml | 2 ++ config/locales/client.nb_NO.yml | 6 +----- config/locales/client.nl.yml | 6 +----- config/locales/client.pl_PL.yml | 6 +----- config/locales/client.pt.yml | 6 +----- config/locales/client.pt_BR.yml | 6 +----- config/locales/client.ro.yml | 6 +----- config/locales/client.ru.yml | 8 +++---- config/locales/client.sk.yml | 6 +----- config/locales/client.sl.yml | 6 ------ config/locales/client.sq.yml | 6 +----- config/locales/client.sr.yml | 6 ------ config/locales/client.sv.yml | 10 +++++---- config/locales/client.sw.yml | 6 +----- config/locales/client.te.yml | 6 +----- config/locales/client.th.yml | 4 ---- config/locales/client.tr_TR.yml | 6 +----- config/locales/client.uk.yml | 6 +----- config/locales/client.ur.yml | 6 +----- config/locales/client.vi.yml | 6 +----- config/locales/client.zh_CN.yml | 6 +----- config/locales/client.zh_TW.yml | 6 +----- config/locales/server.he.yml | 10 ++++----- config/locales/server.uk.yml | 1 + 47 files changed, 104 insertions(+), 217 deletions(-) diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index d2b293fd0e..a2710fbf8e 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -4756,12 +4756,8 @@ ar: %{example} يوصى بشدة بوضع بادئة لأسماء الخصائص لتجنُّب تعارضها مع المكوِّنات الإضافية أو الأساسية. - head_tag: - text: "" - title: "HTML الذي سيتم إدراجه قبل وسم ‎‎" body_tag: - text: "" - title: "HTML الذي سيتم إدراجه قبل وسم ‎‎" + text: "النص الأساسي" yaml: text: "YAML" title: "تحديد إعدادات السمة بتنسيق YAML" diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index f45ae45b1e..b8653f4340 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -1563,8 +1563,8 @@ be: add: "Дадаць" header: text: "Header" - head_tag: - text: "<" + body_tag: + text: "цела" colors: title: "<......" copy_name_prefix: "капіяваць з" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index 804f0e1d70..80fd99d275 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -2444,12 +2444,8 @@ bg: text: "Футър" embedded_scss: text: "ембеднато CSS" - head_tag: - text: "" - title: "HTML код който ще бъде поставен преди тага" body_tag: - text: "" - title: "HTML код който ще бъде поставен преди тага" + text: "Тяло" colors: title: "Цветове" copy_name_prefix: "Копие на" diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index bbe1933958..09cdfcae3c 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -3596,12 +3596,8 @@ bs_BA: embedded_scss: text: "Ugrađeni CSS" title: "Unesite prilagođeni CSS za isporuku s ugrađenom verzijom komentara" - head_tag: - text: "" - title: "HTML koji će biti umetnut prije tag" body_tag: - text: "" - title: "HTML koji će biti umetnut prije tag" + text: "Tijelo" yaml: text: "YAML" title: "Definirajte postavke teme u YAML formatu" diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index 57c29c0223..2fa441bee4 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -3379,12 +3379,8 @@ ca: embedded_scss: text: "CSS incrustat" title: "Introduïu el CSS personalitzat que es lliurarà amb la versió incrustada dels comentaris" - head_tag: - text: "" - title: "HTML que s'inclourà abans de l'etiqueta " body_tag: - text: "" - title: "HTML que s'inclourà abans de l'etiqueta " + text: "Cos" yaml: text: "YAML" title: "Definiu la configuració de l'aparença en format YAML" diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index 0c1ee1756b..092916ec83 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -3215,12 +3215,8 @@ cs: embedded_scss: text: "Vloženo CSS" title: "Zadat vlastní CSS k doručení s vloženou verzí kometářů" - head_tag: - text: "" - title: "HTML které bude vloženo před štítkem" body_tag: - text: "" - title: "HTML, který bude vložený před štítek" + text: "Tělo" yaml: text: "YAML" title: "Definovat nastavení motivu ve formátu YAML" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index ac82b7ed5a..f66e08b18a 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -4070,12 +4070,8 @@ da: %{example} Præfiksering af egenskabsnavne anbefales stærkt for at undgå konflikter med plugins og/eller kernen. - head_tag: - text: "" - title: "HTML som vil blive sat ind før tagget " body_tag: - text: "" - title: "HTML som vil blive sat ind før tagget " + text: "Brødtekst" yaml: text: "YAML" title: "Definér tema indstillinger i YAML-format" diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index 535a6dd413..2ea124ed7e 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -4222,12 +4222,8 @@ de: %{example} Eigenschaften sollten mit einem Präfix versehen werden, um Konflikte mit Discourse und/oder Plug-ins zu vermeiden. - head_tag: - text: "" - title: "HTML, das vor dem -Tag eingefügt wird" body_tag: - text: "" - title: "HTML, das vor dem -Tag eingefügt wird" + text: "Body" yaml: text: "YAML" title: "Definiere Theme-Einstellungen im YAML-Format" diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index 3d2c722833..6886d48b35 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -3643,12 +3643,8 @@ el: color_definitions: text: "Ορισμοί χρωμάτων" title: "Εισαγωγή προσαρμοσμένων ορισμών χρωμάτων (μόνο για προχωρημένους χρήστες)" - head_tag: - text: "" - title: "HTML το οποίο θα προστίθεται πριν το tag" body_tag: - text: "" - title: "HTML το οποίο θα προστίθεται πριν το tag" + text: "Body" yaml: text: "YAML" title: "Ορισμός ρυθμίσεων θέματος σε μορφή YAML" diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index e0b6da5056..dbcdaf3164 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -4216,12 +4216,8 @@ es: %{example} Se recomienda poner prefijos a los nombres de propiedad para evitar conflictos con plugins o el core - head_tag: - text: "" - title: "HTML a insertar antes de la etiqueta " body_tag: - text: "" - title: "HTML a insertar antes de la etiqueta " + text: "Cuerpo" yaml: text: "YAML" title: "Definir ajustes al tema en formato YAML" diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 3337d90df3..78190f321d 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -2899,12 +2899,8 @@ et: text: "Jalus" embedded_scss: text: "Sängitatud CSS" - head_tag: - text: "" - title: "HTML, mida lisatakse enne silti" body_tag: - text: "" - title: "HTML, mida lisatakse enne silti" + text: "Sisu" yaml: text: "YAML" colors: diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index e525caa2d4..e0a1019907 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -3480,12 +3480,8 @@ fa_IR: embedded_scss: text: "کد CSS جاسازی شده" title: "کد CSS سفارشی مد نظرتان را اینجا وارد کنید" - head_tag: - text: "" - title: "کد HTML که قبل از برچسب اضافه می‌شود" body_tag: - text: "" - title: "کد HTML که قبل از برچسب اضافه می‌شود" + text: "بدنه" yaml: text: "YAML" colors: diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index 1c9774c646..329d709a33 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -4098,12 +4098,8 @@ fi: %{example} Etuliitteen lisääminen CSS-kuvauksiin on erittäin suositeltavaa, jotta vältät ristiriidat lisäosien tai ydinsovelluksen kanssa. - head_tag: - text: "" - title: "HTML, joka sijoitetaan ennen -tunnistetta" body_tag: - text: "" - title: "HTML, joka sijoitetaan ennen -tunnistetta" + text: "Leipäteksti" yaml: text: "YAML" title: "Määritä teeman asetukset YAML-muodossa" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index a16ada026c..f8fcd1be1f 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -4096,12 +4096,8 @@ fr: %{example} Il est fortement recommandé de préfixer les noms des propriétés CSS pour éviter tout conflit avec Discourse ou ses extensions. - head_tag: - text: "" - title: "HTML qui sera inséré avant la balise " body_tag: - text: "" - title: "HTML qui sera inséré avant la balise " + text: "Corps" yaml: text: "YAML" title: "Définir les paramètres du thème au format YAML" diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index 2590db6297..b3625bf1f2 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -3970,12 +3970,8 @@ gl: } Prefixar os nomes da propiedade resulta altamente recomendábel para evitarmos conflitos con complementos e/ou o núcleo. - head_tag: - text: "" - title: "HTML que se inserirá antes da etiqueta " body_tag: - text: "" - title: "HTML que se inserirá antes da etiqueta " + text: "Corpo" yaml: text: "YAML" title: "Definir os axustes do tema en formato YAML" diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index 6aa3ad8f5c..88ff729e0d 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -1096,15 +1096,15 @@ he: notifications: "התראות" statistics: "סטטיסטיקות" desktop_notifications: - label: "התראות" + label: "התראות חיות" not_supported: "התראות לא נתמכות בדפדפן זה. עמך הסליחה." perm_default: "הפעלת התראות" perm_denied_btn: "הרשאות נדחו" perm_denied_expl: "דחית הרשאה לקבלת התראות. יש לאפשר התראות בהגדרות הדפדפן שלך." - disable: "כבוי התראות" - enable: "אפשר התראות" + disable: "השבתת התראות" + enable: "הפעלת התראות" each_browser_note: 'הערה: עליך לשנות הגדרה זו בכל דפדפן שבו אתה משתמש. כל ההודעות יושבתו במצב "אל תפריע", ללא קשר להגדרה זו.' - consent_prompt: "האם ברצונך לקבל התראות כשאנשים מגיבים לפוסטים שלך?" + consent_prompt: "לקבל התראות חיות כשמתקבלות תגובות לפוסטים שלך?" dismiss: "דחה" dismiss_notifications: "בטלו הכל" dismiss_notifications_tooltip: "סימון כל ההתראות שלא נקראו כהתראות שנקראו" @@ -1182,7 +1182,7 @@ he: watched_first_post_tags_instructions: "אתם תיודעו לגבי הפוסט הראשון בכל נושא חדש בתגיות אלו." muted_categories: "מושתק" muted_categories_instructions: "לא תקבל הודעה בנוגע לנושאים חדשים בקטגוריות אלה, והם לא יופיעו בקטגוריות או בדפים האחרונים." - muted_categories_instructions_dont_hide: "לא תישלחנה אליך התראות על שום דבר בנוגע לנושאים בקטגוריות האלו." + muted_categories_instructions_dont_hide: "לא תישלחנה אליך התראות על שום דבר שנוגע לנושאים בקטגוריות האלו." regular_categories: "רגילות" regular_categories_instructions: "הקטגוריות האלו תופענה תחת רשימות הנושאים „אחרונים” ו־„מובילים”." no_category_access: "בתור פיקוח יש לך גישה מוגבלת לקטגוריות, שמירה מושבתת." @@ -4512,11 +4512,11 @@ he: מומלץ מאוד להוסיף קידומת לשמות המאפיינים כדי להימנע מסתירות מול תוספים ו/או הליבה. head_tag: - text: "" - title: "HTML שיוכנס לפני התג " + text: "Head" + title: "HTML שיתווסף לפני התגית ‎head" body_tag: - text: "" - title: "HTML שיוכנס לפני התג " + text: "Body" + title: "HTML שיתווסף לפני התגית body" yaml: text: "YAML" title: "עריכת הגדרות ערכת העיצוב בתצורת YAML" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index c2af986fbe..cda7608965 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -1647,8 +1647,11 @@ hu: description: one: "%{count} válasz van." other: "%{count} válasz van." + description_time_MF: "{replyCount, plural, one {is # válasz} other {are # válasz}} található, a becsült olvasási idő {readingTime, plural, one {# perc} other {# perc}}." enable: "Téma összefoglalása" disable: "Összes bejegyzés megjelenítése" + short_label: "Összefoglalás" + short_title: "A téma összefoglalásának megjelenítése: a legérdekesebb bejegyzéseket a közösség határozta meg" deleted_filter: enabled_description: "Ez a téma törölt bejegyzéseket is tartalmaz, melyek el lettek rejtve." disabled_description: "A téma törölt bejegyzései is megjelennek." @@ -1676,6 +1679,7 @@ hu: disclaimer: "Csak akkor regisztráljon, ha elfogadja az adatvédelmi szabályzatot és a szolgáltatási feltételeket." title: "Regisztráció" failed: "Valami hiba történt, talán ez az e-mail cím már regisztrálva van. Próbálta már a jelszó-emlékeztetőt?" + associate: "Már van fiókja? Jelentkezzen be hogy hozzákapcsolja a(z) %{provider}-fiókját." forgot_password: title: "Jelszó-visszaállítás" action: "Elfelejtettem a jelszavamat" @@ -1705,13 +1709,23 @@ hu: logging_in_as: Bejelentkezés mint %{email} confirm_button: Bejelentkezés befejezése login: + header_title: "Üdvözlünk újra" subheader_title: "Jelentkezzen be a fiókjába" + title: "Bejelentkezés" username: "Felhasználó" password: "Jelszó" second_factor_title: "Kétfaktoros hitelesítés" second_factor_description: "Írja be a hitelesítési kódot az alkalmazásából:" + second_factor_backup: "Bejelentkezés biztonsági kóddal" + second_factor_backup_title: "Kétfaktoros biztonsági kód" second_factor_backup_description: "Írja be valamelyik biztonsági kódját:" + second_factor: "Bejelentkezés hitelesítő alkalmazással" + security_key_description: "Ha előkészítette a fizikai biztonsági kulcsot, nyomja meg a lenti „Hitelesítés biztonsági kulccsal” gombot." + security_key_alternative: "Próbálkozás más módon" + security_key_authenticate: "Hitelesítés biztonsági kulccsal" + security_key_not_allowed_error: "A biztonsági hardverkulcsos hitelesítési folyamata során időtúllépés történt, vagy megszakították." security_key_no_matching_credential_error: "A megadott biztonsági kulcsban nem található megfelelő hitelesítő adat." + security_key_support_missing_error: "Jelenlegi eszköze vagy böngészője nem támogatja a biztonsági kulcsok használatát. Használjon más módszert." email_placeholder: "E-mail/felhasználónév" caps_lock_warning: "A Caps Lock be van kapcsolva" error: "Ismeretlen hiba" @@ -1746,17 +1760,26 @@ hu: twitter: name: "Twitter" title: "Twitterrel" + sr_title: "Bejelentkezés a Twitterrel" instagram: name: "Instagram" title: "Instagrammal" + sr_title: "Bejelentkezés az Instagrammal" facebook: name: "Facebook" title: "Facebookkal" + sr_title: "Bejelentkezés a Facebookkal" github: name: "GitHub" title: "GitHubbal" + sr_title: "Bejelentkezés a GitHubbal" discord: name: "Discord" + title: "Discorddal" + sr_title: "Bejelentkezés a Discorddal" + second_factor_toggle: + totp: "Helyette hitelesítő alkalmazás használata" + backup_code: "Helyette biztonsági kód használata" invites: accept_title: "Meghívás" emoji: "boríték emodzsi" @@ -1788,18 +1811,28 @@ hu: shift: "Shift" ctrl: "Ctrl" alt: "Alt" + enter: "Enter" conditional_loading_section: loading: Betöltés… category_row: topic_count: one: "%{count} téma van ebben a kategóriában" other: "%{count} téma van ebben a kategóriában" + plus_subcategories_title: + one: "%{name} és egy alkategória" + other: "%{name} és %{count} alkategória" + plus_subcategories: + one: "+ %{count} alkategória" + other: "+ %{count} alkategória" select_kit: delete_item: "%{name} törlése" filter_by: "Szűrő: %{name}" select_to_filter: "Válaszd ki a szűrni kívánt értéket" default_header_text: Kiválasztás… no_content: Nem található egyezés + results_count: + one: "%{count} találat" + other: "%{count} találat" filter_placeholder: Keresés… filter_placeholder_with_any: Keresés vagy létrehozás… create: "Létrehozás: „%{content}”" @@ -1852,6 +1885,7 @@ hu: saved_local_draft_tip: "helyben mentett" similar_topics: "A témaköröd hasonlít a..." drafts_offline: "vázlatok offline" + edit_conflict: "szerkesztési ütközés" group_mentioned: one: "A(z) %{group} csoport megemlítésével %{count} embert fog értesíteni – biztos benne?" other: "A(z) %{group} csoport megemlítésével %{count} embert fog értesíteni – biztos benne?" @@ -3333,10 +3367,8 @@ hu: text: "Lábléc" embedded_scss: text: "Ágyazott CSS" - head_tag: - text: "" body_tag: - text: "" + text: "Törzs" yaml: text: "YAML" title: "Téma beállításainak megadása YAML formátumban" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index 8e1c3a4b69..0d8a80fdbf 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -3420,12 +3420,8 @@ hy: embedded_scss: text: "Զետեղված CSS" title: "Մուտքագրեք մասնավոր CSS՝ մեկնաբանությունների զետեղված տարբերակի վրա կիրառելու համար" - head_tag: - text: "" - title: "HTML, որը պետք է մուտք արվի մինչև թեգը" body_tag: - text: "" - title: "HTML, որը պետք է մուտք արվի մինչև թեգը" + text: "Մարմին" yaml: text: "YAML" title: "Սահմանել թեմայի կարգավորումները YAML ֆորմատով" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 10f95e6bcc..f934efd217 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -1770,6 +1770,8 @@ id: enable: "Aktifkan" disable: "Nonaktifkan" add: "Menambahkan" + body_tag: + text: "Konten" colors: undo: "batalkan perintah" revert: "dibalik" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index a0b9af6e52..972f1724dc 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -4172,11 +4172,11 @@ it: Si consiglia di assegnare un prefisso ai nomi delle proprietà per evitare conflitti con i plugin e/o il kernel. head_tag: - text: "" - title: "HTML che sarà inserito prima del tag " + text: "Intestazione" + title: "L'HTML sarà inserito prima dell'etichetta di intestazione" body_tag: - text: "" - title: "HTML che deve essere inserito prima del tag " + text: "Corpo" + title: "HTML che sarà inserito prima dell'etichetta del corpo" yaml: text: "YAML" title: "Definisci le impostazioni del tema in formato YAML" diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index 258d08453f..b325e226b1 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -3947,12 +3947,8 @@ ja: %{example} プラグインやコアとの競合を避けるには、プロパティ名に接頭辞を付けることを強くお勧めします。 - head_tag: - text: "" - title: " タグの前に挿入される HTML" body_tag: - text: "" - title: " タグの前に挿入される HTML" + text: "本文" yaml: text: "YAML" title: "YAML 形式でテーマ設定を定義してください" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index 152b4b6050..5a59e9ec6d 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -4026,12 +4026,8 @@ ko: %{example} 플러그인 및/또는 코어와의 충돌을 피하기 위해 속성 이름 앞에 붙이는 것이 좋습니다. - head_tag: - text: "" - title: " 태그 전에 들어갈 HTML" body_tag: - text: "" - title: " 태그 전에 들어갈 HTML" + text: "바디" yaml: text: "YAML" title: "YAML 형식으로 테마 설정 정의" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index f10774e9ff..aa4046d3bf 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -3876,12 +3876,8 @@ lt: text: "Įterptas CSS" color_definitions: text: "Spalvų apibrėžimai" - head_tag: - text: "" - title: "HTML, kuris bus įstatytas prieš tag'ą" body_tag: - text: "" - title: "HTML, kuris bus įstatytas prieš tag'ą" + text: "Turinys" yaml: text: "YAML" colors: diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 0178bc7749..5b734d3305 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -3134,6 +3134,8 @@ lv: add: "Pievienot" scss: text: "CSS" + body_tag: + text: "Ķermenis" colors: undo: "atsaukt" revert: "atgriezt" diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index 2e06d14dd1..561a611917 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -3948,12 +3948,8 @@ nb_NO: embedded_scss: text: "Innebygd CSS" title: "Skriv inn egendefinert CSS for å levere med innebygde versjoner av kommentarer" - head_tag: - text: "" - title: "HTML som har blitt smettet inn før -taggen" body_tag: - text: "" - title: "HTML som vil bli smettet inn før -taggen" + text: "Body" yaml: text: "YAML" title: "Definer draktinnstillinger i YAML-format" diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index df9c6b95ec..aeafb1459d 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -4019,12 +4019,8 @@ nl: %{example} Het toevoegen van voorvoegsels aan de eigenschapsnamen wordt sterk aanbevolen om conflicten met plug-ins en/of core te voorkomen. - head_tag: - text: "" - title: "HTML die voor de -tag wordt ingevoegd" body_tag: - text: "" - title: "HTML die voor de -tag wordt ingevoegd" + text: "Inhoud" yaml: text: "YAML" title: "Thema-instellingen definiëren in YAML-indeling" diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index c5bd2c3e0d..80a0c7fae5 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -4538,12 +4538,8 @@ pl_PL: %{example} Zdecydowanie zalecamy dodawanie przedrostków do nazw zmiennych, aby uniknąć konfliktów z wtyczkami i/lub silnikiem forum. - head_tag: - text: "" - title: "HTML, który zostanie umieszczony przed tagiem " body_tag: - text: "" - title: "HTML, który zostanie umieszczony przed tagiem " + text: "Body" yaml: text: "YAML" title: "Zdefiniuj ustawienia motywu w formacie YAML" diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index a11723ef14..1e0087745d 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -3882,12 +3882,8 @@ pt: %{example} Prefixar os nomes das propriedades é altamente recomendado para evitar conflitos com plugins e/ou o core. - head_tag: - text: "" - title: "HTML que será introduzido antes da tag " body_tag: - text: "" - title: "HTML que será introduzido antes da tag " + text: "Corpo" yaml: text: "YAML" title: "Defina as configurações do tema no formato YAML" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index bb39d4eb20..58db18aceb 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -4115,12 +4115,8 @@ pt_BR: %{example} É altamente recomendável adicionar os nomes das propriedades ao prefixo para evitar conflitos com plug-ins e/ou núcleo. - head_tag: - text: "" - title: "O HTML que será inserido antes da etiqueta" body_tag: - text: "" - title: "O HTML que será inserido antes da etiqueta" + text: "Corpo" yaml: text: "YAML" title: "Definir configurações de tema no formato YAML" diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index fc0b99da62..0f7f6f7771 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -3109,12 +3109,8 @@ ro: text: "Subsol" embedded_scss: text: "CSS încorporat" - head_tag: - text: "1" - title: "HTML care va fi inserat înainte de 1 tag" body_tag: - text: "1" - title: "HTML care va fi inserat înaintea de tag-ul " + text: "Corp" colors: title: "Culori" copy_name_prefix: "Copie a" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index 0ac536fa72..9b386a6e3c 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -4539,11 +4539,11 @@ ru: title: "Укажите собственные цвета (только для опытных пользователей)" placeholder: "\nИспользуйте эту таблицу стилей для добавления цветов в список пользовательских свойств CSS.\n\nПример: \n\n%{example}\n\nНастоятельно рекомендуется добавлять префиксы к именам свойств, чтобы избежать конфликтов с плагинами и / или движком форума." head_tag: - text: "" - title: "HTML для размещения перед тегом " + text: "Head" + title: "HTML, который будет вставлен перед тегом 'head'" body_tag: - text: "" - title: "HTML для размещения перед тегом " + text: "Body" + title: "HTML, который будет вставлен перед тегом 'body'" yaml: text: "YAML" title: "Определить настройки темы в формате YAML" diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 022f3ec224..a649339785 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -2564,12 +2564,8 @@ sk: text: "Päta" embedded_scss: text: "Vnorené CSS" - head_tag: - text: "" - title: "HTML, ktoré bude vložené pred tag" body_tag: - text: "" - title: "HTML, ktoré bude vložené pred tag" + text: "Telo" colors: title: "Farby" copy_name_prefix: "Kópia" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index 9ab205b141..d88948d742 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -3721,12 +3721,6 @@ sl: text: "CSS" header: text: "Glava" - head_tag: - text: "" - title: "HTML, ki bo vstavljen pred oznako" - body_tag: - text: "" - title: "HTML, ki bo vstavljen pred oznako" colors: title: "Barve" copy_name_prefix: "Kopija od" diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index e0f92304d5..3da6e9df52 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -2180,12 +2180,8 @@ sq: text: "Fundi i faqes" embedded_scss: text: "CSS e ngjitur" - head_tag: - text: "" - title: "HTML që do të vendoset para mbylles së tagut " body_tag: - text: "" - title: "HTML që do të vendoset para mbylljes së tagut " + text: "Trupi" colors: title: "Ngjyrat" copy_name_prefix: "Kopje e" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 719475cd37..8148c1463c 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -1988,12 +1988,6 @@ sr: text: "Header" footer: text: "Footer" - head_tag: - text: "" - title: "HTML koji će biti umetnut pre oznake" - body_tag: - text: "" - title: "HTML koji će biti umetnut pre Topp eller Kategorier eller sök efter sökord %{searchIcon}" no_group_messages_title: "Inga gruppmeddelanden hittades" + fullscreen_table: + expand_btn: "Expandera tabell" admin_js: type_to_filter: "skriv för att filtrera..." admin: @@ -4221,11 +4223,11 @@ sv: Användning av prefix för egenskapsnamnen rekommenderas starkt för att undvika konflikter mellan tillägg och/eller grundläggande program. head_tag: - text: "" - title: "HTML som kommer att sättas in före taggen" + text: "Huvud" + title: "HTML som kommer att infogas före huvudtaggen" body_tag: - text: "" - title: "HTML som kommer att infogas in före taggen " + text: "Huvuddel" + title: "HTML som kommer att infogas innan kroppstaggen" yaml: text: "YAML" title: "Definiera temainställningar i YAML-format" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index 9928975ad6..a7ad7339e4 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -2632,12 +2632,8 @@ sw: title: "Ruhusu HTLM kuonekana kwenye ukurasa wa kijachini" embedded_scss: text: "CSS Iliyoambatanishwa" - head_tag: - text: "" - title: "HTML itakayowekwa kabla ya lebo " body_tag: - text: "" - title: "HTML itakayowekwa kabla ya lebo " + text: "Mwili" yaml: text: "YAML" title: "Tambulisha mipangilio ya mpango kwenye umbiza wa YAML" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index d6a40530c9..1280899a99 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -1561,12 +1561,8 @@ te: text: "హెడర్" footer: text: "ఫుటరు" - head_tag: - text: "" - title: " కొస ముందు ఉంచే హెచ్ టీ యం యల్" body_tag: - text: "" - title: " కొస ముందు ఉంచే హెచ్ టీ యం యల్" + text: "శరీరం" colors: title: "రంగులు" copy_name_prefix: "దీనికి నకలు" diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index b5c04a6749..132e058ab1 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -2665,10 +2665,6 @@ th: text: "Footer" embedded_scss: text: "Embedded CSS" - head_tag: - text: "" - body_tag: - text: "" colors: title: "สี" undo: "เลิกทำ" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index bcd43103e3..064c9fde8e 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -4210,12 +4210,8 @@ tr_TR: %{example} Eklentiler ve/veya ana kod ile çakışmaları önlemek için özellik adlarının önüne önek eklenmesi şiddetle tavsiye edilir. - head_tag: - text: "" - title: " etiketinden önce eklenecek HTML" body_tag: - text: "" - title: " etiketinden önce eklenecek HTML" + text: "İçerik" yaml: text: "YAML" title: "Tema ayarlarını YAML formatında tanımla" diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index 2e20d477b4..5f695b412a 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -4528,12 +4528,8 @@ uk: %{example} Рекомендується вводити префікси до назв властивостей, щоб уникнути конфліктів з плагінами та / або ядром. - head_tag: - text: "" - title: "HTML-код, який буде вставлений перед міткою " body_tag: - text: "" - title: "HTML-код, який буде вставлений перед міткою " + text: "Тіло" yaml: text: "YAML" title: "Визначити налаштування теми в форматі YAML" diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index 48fccc326b..dbb042ca5b 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -3556,12 +3556,8 @@ ur: embedded_scss: text: "اَیمبَیڈ کیا ہوا CSS" title: "تبصرے کے اَیمبَیڈ شدہ ورژن کے ساتھ فراہم کیے جانے والا اپنی مرضی کا CSS درج کریں" - head_tag: - text: "" - title: "HTML جو ٹیگ سے پہلے داخل کی جائے گی" body_tag: - text: "" - title: "HTML جو ٹیگ سے پہلے داخل کی جائے گی" + text: "متن" yaml: text: "YAML" title: "تھیم ترتیبات کی وضاحت YAML فارمَیٹ میں کریں" diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index f05c8935dc..0a486e14bb 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -3784,12 +3784,8 @@ vi: color_definitions: text: "Màu sắc" title: "Nhập màu tùy chỉnh (chỉ dành cho người dùng nâng cao)" - head_tag: - text: "" - title: "HTML sẻ thêm trước thẻ " body_tag: - text: "" - title: "HTML sẽ thêm trước thẻ " + text: "Thân" yaml: text: "YAML" title: "Xác định cài đặt chủ đề ở định dạng YAML" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index d47fa46b7b..82071f6645 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -4015,12 +4015,8 @@ zh_CN: %{example} 强烈建议为属性名称添加前缀以避免与插件和/或核心冲突。 - head_tag: - text: "" - title: "将在 标记前插入的 HTML" body_tag: - text: "" - title: "将在 标记前插入的 HTML" + text: "正文" yaml: text: "YAML" title: "使用 YAML 格式定义主题设置" diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index 9b1a12ffbb..00de17d91d 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -3231,12 +3231,8 @@ zh_TW: embedded_scss: text: "嵌入的 CSS" title: "輸入用於嵌入回應的自定義 CSS 樣式" - head_tag: - text: "" - title: "會在標籤前被插入的 HTML" body_tag: - text: "" - title: "會在標籤前被插入的 HTML" + text: "內容" yaml: text: "YAML" title: "用 YAML 格式定義佈景主題的設定" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 55d11094e6..93b8152a44 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -1782,11 +1782,11 @@ he: tl3_time_period: "דרישות ותק (בימים) לדרגת אמון 3" tl3_requires_days_visited: "מספר הימים המזערי שעל משתמש לבקר באתר במשך (תקופת זמן עם דרגת אמון 3) ימים כדי לעמוד בתנאי הקידום לדרגת אמון 3. אפשר להגדיר לתקופת זמן ממושכת יותר מאשר דרגת אמון 3 כדי להשבית את הקידומים לדרגת אמון 3. (0 ומעלה)" tl3_requires_topics_replied_to: "מספר הנושאים המזערי שעל משתמש להגיב בהם במשך (תקופת זמן עם דרגת אמון 3) ימים כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 ומעלה)" - tl3_requires_topics_viewed: "אחוז הנושאים שנוצרו במהלך (תקופת זמן רמת-אמון-3) הימים האחרונים שמשתמש צריך לצפות בהם כדי שיוכל להיות מועמד לקידום לרמת אמון 3. (0 עד 100)" - tl3_requires_topics_viewed_cap: "המספר המקסימלי הנדרש של נושאים לצפייה ב (תקופת זמן רמת-אמון-3) הימים האחרונים." - tl3_requires_posts_read: "אחוז הנושאים שנוצרו ב (תקופת זמן רמת-אמון-3) הימים האחרונים שעל משתמש לצפות בהם כדי להיות מועמד לרמת אמון 3. (0 עד 100)" - tl3_requires_posts_read_cap: "המספר המקסימלי הנדרש של פוסטים לקריאה ב (תקופת זמן רמת-אמון-3) הימים האחרונים." - tl3_requires_topics_viewed_all_time: "מספר מינימלי של נושאים שמשתמשים צריכים לעיין בהם על מנת שיתאפשר להם להיות משודרגים לרמת אמון 3." + tl3_requires_topics_viewed: "אחוז הנושאים שנוצרו במהלך (תקופה עם דרגת אמון 3) ימים שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 עד 100)" + tl3_requires_topics_viewed_cap: "המספר המרבי הדרוש של צפיות בנושאים במשך (תקופה בדרגת אמון 3) ימים." + tl3_requires_posts_read: "אחוז הפוסטים שנוצרו במהלך (תקופה עם דרגת אמון 3) ימים שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 עד 100)" + tl3_requires_posts_read_cap: "המספר המרבי הדרוש של פוסטים שנקראו במשך (תקופה בדרגת אמון 3) ימים." + tl3_requires_topics_viewed_all_time: "מספר הנושאים הכולל המזערי שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3." tl3_requires_posts_read_all_time: "מספר הפוסטים הכולל המזערי שעל משתמש לקרוא כדי לעמוד בתנאי הקידום לדרגת אמון 3." tl3_requires_max_flagged: "אסור שלמשתמש יהיו יותר מ־x פוסטים שמסומנים בדגל על ידי x משתמשים שונים במשך (משך זמן דרגת אמון 3) הימים האחרונים כדי לעמוד בתנאי הקידום לדרגת אמון 3, כאשר x הוא ערך ההגדרה הזאת. (0 ומעלה)" tl3_promotion_min_duration: "מספר הימים המזערי שקידום לרמת אמון 3 נמשך לפני שניתן להוריד משתמשים בחזרה לדרגת אמון 2." diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index bda41aec66..2d735fa8f3 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -536,6 +536,7 @@ uk: Для більшості людей набагато простіше читати теми, у яких довгі відповіді і їх мало, ніж коли багато коротеньких відповідей. dominating_topic: Ви розмістили тут більше %{percent}% відповідей, можливо є ще хтось, кого варто було би почути? + get_a_room: Ви відповіли користувачу @%{reply_username} %{count} разів, ви знали, що можете надіслати їм особисте повідомлення? too_many_replies: "### Ви досягли межі відповідей в цю тему. \nНа жаль, нові користувачі тимчасово обмежені %{newuser_max_replies_per_topic} відповідей в цій темі. \nЗамість того, щоб додавати ще одну відповідь, будь ласка, подумайте про редагування попередніх відповідей або відвідайте інші теми.\n" reviving_old_topic: | ### Відновити обговорення в цій темі? From 148ee1d1627cda7d65a028bf43f548e1b3efed6a Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Thu, 28 Oct 2021 13:27:31 +0300 Subject: [PATCH 088/254] FIX: Do not perform link lookup for replaced links (#14742) A link that was added because a watched word was replaced could create a notice if the same link was present before. --- app/assets/javascripts/discourse/app/controllers/composer.js | 5 +++++ app/assets/javascripts/pretty-text/addon/allow-lister.js | 1 + .../pretty-text/engines/discourse-markdown/watched-words.js | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 479b70202b..ce6bde3532 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -505,6 +505,11 @@ export default Controller.extend({ $links.each((idx, l) => { const href = l.href; if (href && href.length) { + // skip links added by watched words + if (l.dataset.word !== undefined) { + return true; + } + // skip links in quotes and oneboxes for (let element = l; element; element = element.parentElement) { if ( diff --git a/app/assets/javascripts/pretty-text/addon/allow-lister.js b/app/assets/javascripts/pretty-text/addon/allow-lister.js index 042004452e..a6378ee61c 100644 --- a/app/assets/javascripts/pretty-text/addon/allow-lister.js +++ b/app/assets/javascripts/pretty-text/addon/allow-lister.js @@ -139,6 +139,7 @@ export const DEFAULT_LIST = [ `a.inline-onebox`, `a.inline-onebox-loading`, "a[data-bbcode]", + "a[data-word]", "a[name]", "a[rel=nofollow]", "a[rel=ugc]", diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js index ec7f26fa14..2bcffbaf0b 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js @@ -32,6 +32,8 @@ function findAllMatches(text, matchers) { } export function setup(helper) { + const opts = helper.getOptions(); + helper.registerPlugin((md) => { const matchers = []; @@ -139,6 +141,9 @@ export function setup(helper) { if (htmlLinkLevel === 0 && state.md.validateLink(url)) { token = new state.Token("link_open", "a", 1); token.attrs = [["href", url]]; + if (opts.discourse.previewing) { + token.attrs.push(["data-word", ""]); + } token.level = level++; token.markup = "linkify"; token.info = "auto"; From 00b99c6613516f73c0c0826ab4b937b818e030ce Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Thu, 28 Oct 2021 15:14:23 +0300 Subject: [PATCH 089/254] DEV: Use Rails query builder (#14759) --- lib/imap/sync.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/imap/sync.rb b/lib/imap/sync.rb index 5f034270dc..040910cbc8 100644 --- a/lib/imap/sync.rb +++ b/lib/imap/sync.rb @@ -141,7 +141,7 @@ module Imap message_id: Email.message_id_clean(email['ENVELOPE'].message_id), imap_uid: nil, imap_uid_validity: nil - ).where("to_addresses LIKE '%#{@group.email_username}%'").first + ).where("to_addresses LIKE ?", "%#{@group.email_username}%").first if incoming_email incoming_email.update( From c62242c6b38654b3ebc222a83b8b25749f259098 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 28 Oct 2021 21:03:00 +0530 Subject: [PATCH 090/254] FIX: should not receive topic invites from ignored users. (#14746) Previously, ignored users can send notifications by inviting the ignorer to topics or PMs. --- app/models/topic.rb | 27 ++++++++++++++++++++------- config/locales/server.en.yml | 1 - spec/models/topic_spec.rb | 17 ++++++++++++++--- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index 2dd6f955fa..f79a177054 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1022,13 +1022,7 @@ class Topic < ActiveRecord::Base raise UserExists.new(I18n.t("topic_invite.user_exists")) end - if MutedUser - .where(user: target_user, muted_user: invited_by) - .joins(:muted_user) - .where('NOT admin AND NOT moderator') - .exists? - raise NotAllowed.new(I18n.t("topic_invite.muted_invitee")) - end + ensure_can_invite!(target_user, invited_by) if TopicUser .where(topic: self, @@ -1066,6 +1060,22 @@ class Topic < ActiveRecord::Base end end + def ensure_can_invite!(target_user, invited_by) + if MutedUser + .where(user: target_user, muted_user: invited_by) + .joins(:muted_user) + .where('NOT admin AND NOT moderator') + .exists? + raise NotAllowed + elsif IgnoredUser + .where(user: target_user, ignored_user: invited_by) + .joins(:ignored_user) + .where('NOT admin AND NOT moderator') + .exists? + raise NotAllowed + end + end + def email_already_exists_for?(invite) invite.email_already_exists && private_message? end @@ -1733,6 +1743,9 @@ class Topic < ActiveRecord::Base end def create_invite_notification!(target_user, notification_type, username) + invited_by = User.find_by_username(username) + ensure_can_invite!(target_user, invited_by) + target_user.notifications.create!( notification_type: notification_type, topic_id: self.id, diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ae51ce9838..ce1740ea64 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -270,7 +270,6 @@ en: topic_invite: failed_to_invite: "The user cannot be invited into this topic without a group membership in either one of the following groups: %{group_names}." user_exists: "Sorry, that user has already been invited. You may only invite a user to a topic once." - muted_invitee: "Sorry, that user muted you." muted_topic: "Sorry, that user muted this topic." receiver_does_not_allow_pm: "Sorry, that user does not allow you to send them private messages." sender_does_not_allow_pm: "Sorry, you do not allow that user to send you private messages." diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index e356d11d19..20d1721a8c 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -711,12 +711,23 @@ describe Topic do end context "from a muted user" do - before { MutedUser.create!(user: another_user, muted_user: user) } + before { Fabricate(:muted_user, user: another_user, muted_user: user) } - it 'fails with an error message' do + it 'fails with an error' do + expect { topic.invite(user, another_user.username) } + .to raise_error(Topic::NotAllowed) + expect(topic.allowed_users).to_not include(another_user) + expect(Post.last).to be_blank + expect(Notification.last).to be_blank + end + end + + context "from a ignored user" do + before { Fabricate(:ignored_user, user: another_user, ignored_user: user) } + + it 'fails with an error' do expect { topic.invite(user, another_user.username) } .to raise_error(Topic::NotAllowed) - .with_message(I18n.t("topic_invite.muted_invitee")) expect(topic.allowed_users).to_not include(another_user) expect(Post.last).to be_blank expect(Notification.last).to be_blank From deee715a2cbd7668b5f94f1a79e5012de70779f1 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 28 Oct 2021 22:56:44 +0530 Subject: [PATCH 091/254] FIX: update flair group of all members if primary group setting changed. (#14762) Previously, if we enable the `primary_group` setting on a group then the `flair_group_id` of its' members are not affected. --- app/models/group.rb | 33 ++++++++++++++++++--------------- spec/models/group_spec.rb | 9 ++++++++- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 374058faf7..889df6614b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -979,23 +979,26 @@ class Group < ActiveRecord::Base /*where*/ SQL - builder = DB.build(sql) - builder.where(<<~SQL, id: id) - id IN ( - SELECT user_id - FROM group_users - WHERE group_id = :id - ) - SQL + [:primary_group_id, :flair_group_id].each do |column| + builder = DB.build(sql) + builder.where(<<~SQL, id: id) + id IN ( + SELECT user_id + FROM group_users + WHERE group_id = :id + ) + SQL - if primary_group - builder.set("primary_group_id = :id") - else - builder.set("primary_group_id = NULL") - builder.where("primary_group_id = :id") + if primary_group + builder.set("#{column} = :id") + builder.where("#{column} IS NULL") if column == :flair_group_id + else + builder.set("#{column} = NULL") + builder.where("#{column} = :id") + end + + builder.exec end - - builder.exec end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index c72f6f869d..7f4aed0a0d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -198,12 +198,19 @@ describe Group do end describe '#primary_group=' do - it "updates all members' #primary_group" do + before do group.add(user) + end + it "updates all members' #primary_group" do expect { group.update(primary_group: true) }.to change { user.reload.primary_group }.from(nil).to(group) expect { group.update(primary_group: false) }.to change { user.reload.primary_group }.from(group).to(nil) end + + it "updates all members' #flair_group" do + expect { group.update(primary_group: true) }.to change { user.reload.flair_group }.from(nil).to(group) + expect { group.update(primary_group: false) }.to change { user.reload.flair_group }.from(group).to(nil) + end end describe '#title=' do From 85d0ddb9eba698f47dc678b12a7dc4855bca14d8 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 28 Oct 2021 20:28:31 +0200 Subject: [PATCH 092/254] FIX: Avoid another N+1 query in `Site.json_for` (#14763) A follow-up to #14729, this time for logged-in users and/or non-login-required sites. --- app/models/site.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index b1d3dcaa5d..d8ba6cf083 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -32,7 +32,7 @@ class Site end def user_fields - UserField.order(:position).all + UserField.includes(:user_field_options).order(:position).all end def self.categories_cache_key @@ -142,7 +142,7 @@ class Site return { periods: TopTopic.periods.map(&:to_s), filters: Discourse.filters.map(&:to_s), - user_fields: UserField.includes(:user_field_options).all.map do |userfield| + user_fields: UserField.includes(:user_field_options).order(:position).all.map do |userfield| UserFieldSerializer.new(userfield, root: false, scope: guardian) end, auth_providers: Discourse.enabled_auth_providers.map do |provider| @@ -161,7 +161,6 @@ class Site if cached_json && seq == cached_seq.to_i && Discourse.git_version == cached_version return cached_json end - end site = Site.new(guardian) From b1603c866781dc3d88a97cf3e1ccd1f33b6c7163 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Fri, 29 Oct 2021 03:03:11 +0200 Subject: [PATCH 093/254] FIX: Regression introduced in #14715 (#14765) --- lib/discourse_updates.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/discourse_updates.rb b/lib/discourse_updates.rb index 32afc2f6d2..297cda6e10 100644 --- a/lib/discourse_updates.rb +++ b/lib/discourse_updates.rb @@ -78,7 +78,7 @@ module DiscourseUpdates end def missing_versions_count=(arg) - Discourse.redis.set(missing_versions_count, arg) + Discourse.redis.set(missing_versions_count_key, arg) end def critical_updates_available? @@ -86,7 +86,7 @@ module DiscourseUpdates end def critical_updates_available=(arg) - Discourse.redis.set(critical_updates_available, arg) + Discourse.redis.set(critical_updates_available_key, arg) end def updated_at From 19c9b892dcd2f7d7c811a9925127776d4b4fb0f3 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Fri, 29 Oct 2021 03:03:22 +0200 Subject: [PATCH 094/254] DEV: Instantiate relation early to save a query (#14766) Previously it would do `SELECT 1 AS one` and then `SELECT "color_scheme_colors".*`. Now it only does the latter. --- app/models/color_scheme.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index bcd7d7c02f..afb897f9b4 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -346,7 +346,7 @@ class ColorScheme < ActiveRecord::Base end def is_dark? - return if colors.empty? + return if colors.to_a.empty? primary_b = brightness(colors_by_name["primary"].hex) secondary_b = brightness(colors_by_name["secondary"].hex) From 095421a1e129e870a5e7dfd0e78116dac05a7a31 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 29 Oct 2021 09:23:15 -0400 Subject: [PATCH 095/254] REFACTOR: Use IntersectionObserver to calculate topic progress position (#14698) --- .../app/components/topic-navigation.js | 4 + .../app/components/topic-progress.js | 158 +++++++----------- .../templates/components/topic-progress.hbs | 3 +- .../discourse/app/widgets/topic-admin-menu.js | 2 +- .../stylesheets/common/base/topic-post.scss | 2 +- app/assets/stylesheets/common/base/topic.scss | 97 ++++++++--- .../common/components/footer-nav.scss | 9 +- .../stylesheets/common/topic-timeline.scss | 20 ++- .../stylesheets/desktop/topic-post.scss | 4 +- app/assets/stylesheets/desktop/topic.scss | 85 +--------- app/assets/stylesheets/mobile/topic-post.scss | 6 +- app/assets/stylesheets/mobile/topic.scss | 71 -------- 12 files changed, 175 insertions(+), 286 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/topic-navigation.js b/app/assets/javascripts/discourse/app/components/topic-navigation.js index 77d1dee893..aad6ca4420 100644 --- a/app/assets/javascripts/discourse/app/components/topic-navigation.js +++ b/app/assets/javascripts/discourse/app/components/topic-navigation.js @@ -13,6 +13,10 @@ const MIN_WIDTH_TIMELINE = 924, MIN_HEIGHT_TIMELINE = 325; export default Component.extend(PanEvents, { + classNameBindings: [ + "info.topicProgressExpanded:topic-progress-expanded", + "info.renderTimeline:render-timeline", + ], composerOpen: null, info: null, isPanning: false, diff --git a/app/assets/javascripts/discourse/app/components/topic-progress.js b/app/assets/javascripts/discourse/app/components/topic-progress.js index a6fea0ea89..22661d04ca 100644 --- a/app/assets/javascripts/discourse/app/components/topic-progress.js +++ b/app/assets/javascripts/discourse/app/components/topic-progress.js @@ -1,4 +1,4 @@ -import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; import Component from "@ember/component"; import I18n from "I18n"; import { alias } from "@ember/object/computed"; @@ -68,128 +68,100 @@ export default Component.extend({ return readPos < stream.length - 1 && readPos > position; }, - @observes("postStream.stream.[]") - _updateBar() { - scheduleOnce("afterRender", this, this._updateProgressBar); - }, - _topicScrolled(event) { if (this.docked) { - this.set("progressPosition", this.get("postStream.filteredPostsCount")); - this._streamPercentage = 1.0; + this.setProperties({ + progressPosition: this.get("postStream.filteredPostsCount"), + _streamPercentage: 100, + }); } else { - this.set("progressPosition", event.postIndex); - this._streamPercentage = event.percent; + this.setProperties({ + progressPosition: event.postIndex, + _streamPercentage: (event.percent * 100).toFixed(2), + }); } + }, - this._updateBar(); + @discourseComputed("_streamPercentage") + progressStyle(_streamPercentage) { + return `--progress-bg-width: ${_streamPercentage || 0}%`; }, didInsertElement() { this._super(...arguments); this.appEvents - .on("composer:will-open", this, this._dock) - .on("composer:resized", this, this._dock) - .on("composer:closed", this, this._dock) - .on("topic:scrolled", this, this._dock) + .on("composer:resized", this, this._composerEvent) .on("topic:current-post-scrolled", this, this._topicScrolled); - const prevEvent = this.prevEvent; - if (prevEvent) { - scheduleOnce("afterRender", this, this._topicScrolled, prevEvent); - } else { - scheduleOnce("afterRender", this, this._updateProgressBar); + if (this.prevEvent) { + scheduleOnce("afterRender", this, this._topicScrolled, this.prevEvent); } - scheduleOnce("afterRender", this, this._dock); + scheduleOnce("afterRender", this, this._startObserver); }, willDestroyElement() { this._super(...arguments); + this._topicBottomObserver?.disconnect(); this.appEvents - .off("composer:will-open", this, this._dock) - .off("composer:resized", this, this._dock) - .off("composer:closed", this, this._dock) - .off("topic:scrolled", this, this._dock) + .off("composer:resized", this, this._composerEvent) .off("topic:current-post-scrolled", this, this._topicScrolled); }, - _updateProgressBar() { - if (this.isDestroyed || this.isDestroying) { - return; - } - - const $topicProgress = $(this.element.querySelector("#topic-progress")); - // speeds up stuff, bypass jquery slowness and extra checks - if (!this._totalWidth) { - this._totalWidth = $topicProgress[0].offsetWidth; - } - - // Only show percentage once we have one - if (!this._streamPercentage) { - return; - } - - const totalWidth = this._totalWidth; - const progressWidth = (this._streamPercentage || 0) * totalWidth; - const borderSize = progressWidth === totalWidth ? "0px" : "1px"; - - const $bg = $topicProgress.find(".bg"); - if ($bg.length === 0) { - const style = `border-right-width: ${borderSize}; width: ${progressWidth}px`; - $topicProgress.append(`
 
`); - } else { - $bg.css("border-right-width", borderSize).width(progressWidth - 2); + _startObserver() { + if ("IntersectionObserver" in window) { + this._topicBottomObserver = this._setupObserver(); + this._topicBottomObserver.observe( + document.querySelector("#topic-bottom") + ); } }, - _dock() { - const $wrapper = $(this.element); - if (!$wrapper || $wrapper.length === 0) { - return; - } + _setupObserver() { + const composerH = + document.querySelector("#reply-control")?.clientHeight || 0; - const $html = $("html"); - const offset = window.pageYOffset || $html.scrollTop(); - const maximumOffset = $("#topic-bottom").offset().top; - const windowHeight = $(window).height(); - let composerHeight = $("#reply-control").height() || 0; - const isDocked = offset >= maximumOffset - windowHeight + composerHeight; - let bottom = $("body").height() - maximumOffset; - - const $iPadFooterNav = $(".footer-nav-ipad .footer-nav"); - if ($iPadFooterNav && $iPadFooterNav.length > 0) { - bottom += $iPadFooterNav.outerHeight(); - } - - const draftComposerHeight = 40; - - if (composerHeight > 0) { - const $iPhoneFooterNav = $(".footer-nav-visible .footer-nav"); - const $replyDraft = $("#reply-control.draft"); - if ($iPhoneFooterNav.outerHeight() && $replyDraft.outerHeight()) { - composerHeight = - $replyDraft.outerHeight() + $iPhoneFooterNav.outerHeight(); - } - $wrapper.css("bottom", isDocked ? bottom : composerHeight); - } else { - $wrapper.css("bottom", isDocked ? bottom : ""); - } - - this.set("docked", isDocked); - - $wrapper.css( - "margin-bottom", - !isDocked && composerHeight > draftComposerHeight ? "0px" : "" - ); - this.appEvents.trigger("topic-progress:docked-status-changed", { - docked: isDocked, - element: this.element, + return new IntersectionObserver(this._intersectionHandler, { + threshold: 0.1, + rootMargin: `0px 0px -${composerH}px 0px`, }); }, + _composerEvent() { + // reinitializing needed to account for composer height + // might be no longer necessary if IntersectionObserver API supports dynamic rootMargin + // see https://github.com/w3c/IntersectionObserver/issues/428 + if ("IntersectionObserver" in window) { + this._topicBottomObserver?.disconnect(); + this._startObserver(); + } + }, + + @bind + _intersectionHandler(entries) { + if (entries[0].isIntersecting === true) { + this.set("docked", true); + } else { + if (entries[0].boundingClientRect.top > 0) { + this.set("docked", false); + const wrapper = document.querySelector("#topic-progress-wrapper"); + const composerH = + document.querySelector("#reply-control")?.clientHeight || 0; + if (composerH === 0) { + const filteredPostsHeight = + document.querySelector(".posts-filtered-notice")?.clientHeight || 0; + filteredPostsHeight === 0 + ? wrapper.style.removeProperty("bottom") + : wrapper.style.setProperty("bottom", `${filteredPostsHeight}px`); + } else { + wrapper.style.setProperty("bottom", `${composerH}px`); + } + } + } + }, + click(e) { - if ($(e.target).closest("#topic-progress").length) { + if (e.target.closest("#topic-progress")) { this.send("toggleExpansion"); } }, diff --git a/app/assets/javascripts/discourse/app/templates/components/topic-progress.hbs b/app/assets/javascripts/discourse/app/templates/components/topic-progress.hbs index 326aeb3538..0ec7380721 100644 --- a/app/assets/javascripts/discourse/app/templates/components/topic-progress.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/topic-progress.hbs @@ -8,7 +8,7 @@ {{/if}} -