From b57b079ff278d5f9b6ea5134acc2b4b0db9fba7d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 21 Oct 2021 12:42:46 +0100 Subject: [PATCH] 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 ac1ba6d3af3..346dcef1afd 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 531e8619739..a84853465b8 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 4e41c6c62ec..64be78e1ca4 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 19a82d03095..6e3343800c0 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 f38ac5c5822..42e504cee71 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 7db5048f674..00000000000 --- 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 00000000000..e302a3a585c --- /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 ae24b630737..00000000000 --- 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 75ca86b4a4a..00000000000 --- 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 c8514c7edcb..5b767869609 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 75ca86b4a4a..00000000000 --- 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 d20f4a2b1d3..6001eaa0718 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 00000000000..2889a7d287f --- /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 adededeeb3a..00000000000 --- 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 00000000000..3b64064c82d --- /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"); + }); +});