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"); + }); +});