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 1e782973760..e6a366087b9 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,12 +1,11 @@
-import { cancel, debounce, once } from "@ember/runloop";
import Component from "@ember/component";
-import { equal, gt } from "@ember/object/computed";
-import { Promise } from "rsvp";
-import { ajax } from "discourse/lib/ajax";
-import computed, { observes, on } from "discourse-common/utils/decorators";
-
-export const keepAliveDuration = 10000;
-export const bufferTime = 3000;
+import { cancel } from "@ember/runloop";
+import { equal, gt, readOnly } from "@ember/object/computed";
+import discourseComputed, {
+ observes,
+ on
+} from "discourse-common/utils/decorators";
+import { REPLYING, CLOSED, EDITING } from "../lib/presence-manager";
export default Component.extend({
// Passed in variables
@@ -15,115 +14,67 @@ export default Component.extend({
topic: null,
reply: null,
title: null,
+ isWhispering: null,
- // Internal variables
- previousState: null,
- currentState: null,
- presenceUsers: null,
- channel: null,
-
+ presenceManager: readOnly("topic.presenceManager"),
+ users: readOnly("presenceManager.users"),
+ editingUsers: readOnly("presenceManager.editingUsers"),
isReply: equal("action", "reply"),
- shouldDisplay: gt("users.length", 0),
@on("didInsertElement")
- composerOpened() {
- this._lastPublish = new Date();
- once(this, "updateState");
+ subscribe() {
+ this.presenceManager && this.presenceManager.subscribe();
},
- @observes("action", "post.id", "topic.id")
- composerStateChanged() {
- once(this, "updateState");
+ @discourseComputed(
+ "post.id",
+ "editingUsers.@each.last_seen",
+ "users.@each.last_seen"
+ )
+ presenceUsers(postId, editingUsers, users) {
+ if (postId) {
+ return editingUsers.filterBy("post_id", postId);
+ } else {
+ return users;
+ }
},
+ shouldDisplay: gt("presenceUsers.length", 0),
+
@observes("reply", "title")
typing() {
- if (new Date() - this._lastPublish > keepAliveDuration) {
- this.publish({ current: this.currentState });
+ if (this.presenceManager) {
+ const postId = this.get("post.id");
+
+ this._throttle = this.presenceManager.throttlePublish(
+ postId ? EDITING : REPLYING,
+ this.whisper,
+ postId
+ );
+ }
+ },
+
+ @observes("whisper")
+ cancelThrottle() {
+ this._cancelThrottle();
+ },
+
+ @observes("post.id")
+ stopEditing() {
+ if (this.presenceManager && !this.get("post.id")) {
+ this.presenceManager.publish(CLOSED, this.whisper);
}
},
@on("willDestroyElement")
composerClosing() {
- this.publish({ previous: this.currentState });
- cancel(this._pingTimer);
- cancel(this._clearTimer);
- },
-
- updateState() {
- let state = null;
- const action = this.action;
-
- if (action === "reply" || action === "edit") {
- state = { action };
- if (action === "reply") state.topic_id = this.get("topic.id");
- if (action === "edit") state.post_id = this.get("post.id");
+ if (this.presenceManager) {
+ this._cancelThrottle();
+ this.presenceManager.publish(CLOSED, this.whisper);
}
-
- this.set("previousState", this.currentState);
- this.set("currentState", state);
},
- @observes("currentState")
- currentStateChanged() {
- if (this.channel) {
- this.messageBus.unsubscribe(this.channel);
- this.set("channel", null);
- }
-
- this.clear();
-
- if (!["reply", "edit"].includes(this.action)) {
- return;
- }
-
- this.publish({
- response_needed: true,
- previous: this.previousState,
- current: this.currentState
- }).then(r => {
- if (this.isDestroyed) {
- return;
- }
- this.set("presenceUsers", r.users);
- this.set("channel", r.messagebus_channel);
-
- if (!r.messagebus_channel) {
- return;
- }
-
- this.messageBus.subscribe(
- r.messagebus_channel,
- message => {
- if (!this.isDestroyed) this.set("presenceUsers", message.users);
- this._clearTimer = debounce(
- this,
- "clear",
- keepAliveDuration + bufferTime
- );
- },
- r.messagebus_id
- );
- });
- },
-
- clear() {
- if (!this.isDestroyed) this.set("presenceUsers", []);
- },
-
- publish(data) {
- this._lastPublish = new Date();
-
- // Don't publish presence if disabled
- if (this.currentUser.hide_profile_and_presence) {
- return Promise.resolve();
- }
-
- return ajax("/presence/publish", { type: "POST", data });
- },
-
- @computed("presenceUsers", "currentUser.id")
- users(users, currentUserId) {
- return (users || []).filter(user => user.id !== currentUserId);
+ _cancelThrottle() {
+ cancel(this._throttle);
}
});
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 1089246fb65..9cfb46f7ba1 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,59 +1,21 @@
-import { cancel, debounce } from "@ember/runloop";
import Component from "@ember/component";
-import { gt } from "@ember/object/computed";
-import computed, { on } from "discourse-common/utils/decorators";
-import {
- keepAliveDuration,
- bufferTime
-} from "discourse/plugins/discourse-presence/discourse/components/composer-presence-display";
-
-const MB_GET_LAST_MESSAGE = -2;
+import { gt, readOnly } from "@ember/object/computed";
+import { on } from "discourse-common/utils/decorators";
export default Component.extend({
- topicId: null,
- presenceUsers: null,
+ topic: null,
+ presenceManager: readOnly("topic.presenceManager"),
+ users: readOnly("presenceManager.users"),
shouldDisplay: gt("users.length", 0),
- clear() {
- if (!this.isDestroyed) this.set("presenceUsers", []);
- },
-
@on("didInsertElement")
- _inserted() {
- this.clear();
-
- this.messageBus.subscribe(
- this.channel,
- message => {
- if (!this.isDestroyed) this.set("presenceUsers", message.users);
- this._clearTimer = debounce(
- this,
- "clear",
- keepAliveDuration + bufferTime
- );
- },
- MB_GET_LAST_MESSAGE
- );
+ subscribe() {
+ this.get("presenceManager").subscribe();
},
@on("willDestroyElement")
_destroyed() {
- cancel(this._clearTimer);
- this.messageBus.unsubscribe(this.channel);
- },
-
- @computed("topicId")
- channel(topicId) {
- return `/presence/topic/${topicId}`;
- },
-
- @computed("presenceUsers", "currentUser.{id,ignored_users}")
- users(users, currentUser) {
- const ignoredUsers = currentUser.ignored_users || [];
- return (users || []).filter(
- user =>
- user.id !== currentUser.id && !ignoredUsers.includes(user.username)
- );
+ this.get("presenceManager").unsubscribe();
}
});
diff --git a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6
new file mode 100644
index 00000000000..fe5c1bd6b62
--- /dev/null
+++ b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6
@@ -0,0 +1,201 @@
+import EmberObject from "@ember/object";
+import { cancel, later, throttle } from "@ember/runloop";
+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
+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";
+
+const PresenceManager = EmberObject.extend({
+ users: null,
+ editingUsers: null,
+ subscribed: null,
+ topic: null,
+ currentUser: null,
+ messageBus: null,
+ siteSettings: null,
+
+ init() {
+ this._super(...arguments);
+
+ this.setProperties({
+ users: [],
+ editingUsers: [],
+ subscribed: false
+ });
+ },
+
+ subscribe() {
+ if (this.subscribed) return;
+
+ 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.set("subscribed", true);
+ },
+
+ unsubscribe() {
+ this.messageBus.unsubscribe(this.channel);
+ this._stopTimer();
+ this.set("subscribed", false);
+ },
+
+ @discourseComputed("topic.id")
+ channel(topicId) {
+ return `/presence/${topicId}`;
+ },
+
+ throttlePublish(state, whisper, postId) {
+ return throttle(
+ this,
+ this.publish,
+ state,
+ whisper,
+ postId,
+ KEEP_ALIVE_DURATION_SECONDS * 1000
+ );
+ },
+
+ publish(state, whisper, postId) {
+ const data = {
+ state,
+ topic_id: this.get("topic.id")
+ };
+
+ if (whisper) {
+ data.is_whisper = 1;
+ }
+
+ if (postId) {
+ data.post_id = postId;
+ }
+
+ return ajax("/presence/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 PresenceManager;
diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs
index 97936135d0f..453b13d07a7 100644
--- a/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs
+++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs
@@ -1,7 +1,7 @@
{{#if shouldDisplay}}
- {{#each users as |user|}}
+ {{#each presenceUsers as |user|}}
{{avatar user avatarTemplatePath="avatar_template" usernamePath="username" imageSize="small"}}
{{/each}}
@@ -16,4 +16,4 @@
--}}
...
-{{/if}}
\ No newline at end of file
+{{/if}}
diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs
index af4de7c592b..8fa1fb3862c 100644
--- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs
+++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs
@@ -4,4 +4,5 @@
topic=model.topic
reply=model.reply
title=model.title
+ whisper=model.whisper
}}
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 0ee449cf1da..c8514c7edcb 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 @@
-{{topic-presence-display topicId=model.id}}
+{{topic-presence-display topic=model}}
diff --git a/plugins/discourse-presence/assets/javascripts/initializers/discourse-presence.js.es6 b/plugins/discourse-presence/assets/javascripts/initializers/discourse-presence.js.es6
new file mode 100644
index 00000000000..1b591ce4d13
--- /dev/null
+++ b/plugins/discourse-presence/assets/javascripts/initializers/discourse-presence.js.es6
@@ -0,0 +1,42 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+import PresenceManager from "../discourse/lib/presence-manager";
+
+function initializeDiscoursePresence(api) {
+ const currentUser = api.getCurrentUser();
+ const siteSettings = api.container.lookup("site-settings:main");
+
+ if (currentUser && !currentUser.hide_profile_and_presence) {
+ api.modifyClass("model:topic", {
+ presenceManager: null
+ });
+
+ api.modifyClass("route:topic-from-params", {
+ setupController() {
+ this._super(...arguments);
+
+ this.modelFor("topic").set(
+ "presenceManager",
+ PresenceManager.create({
+ topic: this.modelFor("topic"),
+ currentUser,
+ messageBus: api.container.lookup("message-bus:main"),
+ siteSettings
+ })
+ );
+ }
+ });
+ }
+}
+
+export default {
+ name: "discourse-presence",
+ after: "message-bus",
+
+ initialize(container) {
+ const siteSettings = container.lookup("site-settings:main");
+
+ if (siteSettings.presence_enabled) {
+ withPluginApi("0.8.40", initializeDiscoursePresence);
+ }
+ }
+};
diff --git a/plugins/discourse-presence/config/settings.yml b/plugins/discourse-presence/config/settings.yml
index 07c96c3d2b8..21fbddf5ff0 100644
--- a/plugins/discourse-presence/config/settings.yml
+++ b/plugins/discourse-presence/config/settings.yml
@@ -4,5 +4,6 @@ plugins:
client: true
presence_max_users_shown:
default: 5
+ client: true
min: 1
max: 50
diff --git a/plugins/discourse-presence/plugin.rb b/plugins/discourse-presence/plugin.rb
index 54d694b069e..ce91b1ad2e5 100644
--- a/plugins/discourse-presence/plugin.rb
+++ b/plugins/discourse-presence/plugin.rb
@@ -2,8 +2,8 @@
# name: discourse-presence
# about: Show which users are writing a reply to a topic
-# version: 1.0
-# authors: André Pereira, David Taylor
+# version: 2.0
+# authors: André Pereira, David Taylor, tgxworld
# url: https://github.com/discourse/discourse/tree/master/plugins/discourse-presence
enabled_site_setting :presence_enabled
@@ -15,161 +15,155 @@ PLUGIN_NAME ||= -"discourse-presence"
after_initialize do
+ MessageBus.register_client_message_filter('/presence/') do |message|
+ published_at = message.data["published_at"]
+
+ 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
- module ::Presence::PresenceManager
- MAX_BACKLOG_AGE ||= 60
-
- def self.get_redis_key(type, id)
- "presence:#{type}:#{id}"
- end
-
- def self.get_messagebus_channel(type, id)
- "/presence/#{type}/#{id}"
- end
-
- # return true if a key was added
- def self.add(type, id, user_id)
- key = get_redis_key(type, id)
- result = Discourse.redis.hset(key, user_id, Time.zone.now)
- Discourse.redis.expire(key, MAX_BACKLOG_AGE)
- result
- end
-
- # return true if a key was deleted
- def self.remove(type, id, user_id)
- key = get_redis_key(type, id)
- Discourse.redis.expire(key, MAX_BACKLOG_AGE)
- Discourse.redis.hdel(key, user_id) > 0
- end
-
- def self.get_users(type, id)
- user_ids = Discourse.redis.hkeys(get_redis_key(type, id)).map(&:to_i)
- User.where(id: user_ids)
- end
-
- def self.publish(type, id)
- users = get_users(type, id)
- serialized_users = users.map { |u| BasicUserSerializer.new(u, root: false) }
- message = { users: serialized_users, time: Time.now.to_i }
- messagebus_channel = get_messagebus_channel(type, id)
-
- topic = type == 'post' ? Post.find_by(id: id).topic : Topic.find_by(id: id)
-
- if topic.private_message?
- user_ids = User.where('admin OR moderator').pluck(:id) + topic.allowed_users.pluck(:id)
- group_ids = topic.allowed_groups.pluck(:id)
-
- MessageBus.publish(
- messagebus_channel,
- message.as_json,
- user_ids: user_ids,
- group_ids: group_ids,
- max_backlog_age: MAX_BACKLOG_AGE
- )
- else
- MessageBus.publish(
- messagebus_channel,
- message.as_json,
- group_ids: topic.secure_group_ids,
- max_backlog_age: MAX_BACKLOG_AGE
- )
- end
-
- users
- end
-
- def self.cleanup(type, id)
- has_changed = false
-
- # Delete entries older than 20 seconds
- hash = Discourse.redis.hgetall(get_redis_key(type, id))
- hash.each do |user_id, time|
- if Time.zone.now - Time.parse(time) >= 20
- has_changed |= remove(type, id, user_id)
- end
- end
-
- has_changed
- end
-
- end
-
require_dependency "application_controller"
class Presence::PresencesController < ::ApplicationController
requires_plugin PLUGIN_NAME
before_action :ensure_logged_in
+ before_action :ensure_presence_enabled
- ACTIONS ||= [-"edit", -"reply"].freeze
+ EDITING_STATE = 'editing'
+ REPLYING_STATE = 'replying'
+ CLOSED_STATE = 'closed'
- def publish
- raise Discourse::NotFound if current_user.blank? || current_user.user_option.hide_profile_and_presence?
-
- data = params.permit(
- :response_needed,
- current: [:action, :topic_id, :post_id],
- previous: [:action, :topic_id, :post_id]
- )
-
- payload = {}
-
- if data[:previous] && data[:previous][:action].in?(ACTIONS)
- type = data[:previous][:post_id] ? 'post' : 'topic'
- id = data[:previous][:post_id] ? data[:previous][:post_id] : data[:previous][:topic_id]
-
- topic = type == 'post' ? Post.find_by(id: id)&.topic : Topic.find_by(id: id)
-
- if topic
- guardian.ensure_can_see!(topic)
-
- Presence::PresenceManager.remove(type, id, current_user.id)
- Presence::PresenceManager.cleanup(type, id)
- Presence::PresenceManager.publish(type, id)
- end
+ def handle_message
+ [:state, :topic_id].each do |key|
+ raise ActionController::ParameterMissing.new(key) unless params.key?(key)
end
- if data[:current] && data[:current][:action].in?(ACTIONS)
- type = data[:current][:post_id] ? 'post' : 'topic'
- id = data[:current][:post_id] ? data[:current][:post_id] : data[:current][:topic_id]
+ topic_id = permitted_params[:topic_id]
+ topic = Topic.find_by(id: topic_id)
- topic = type == 'post' ? Post.find_by(id: id)&.topic : Topic.find_by(id: id)
+ raise Discourse::InvalidParameters.new(:topic_id) unless topic
+ guardian.ensure_can_see!(topic)
- if topic
- guardian.ensure_can_see!(topic)
+ post = nil
- Presence::PresenceManager.add(type, id, current_user.id)
- Presence::PresenceManager.cleanup(type, id)
- users = Presence::PresenceManager.publish(type, id)
+ if (permitted_params[:post_id])
+ if (permitted_params[:state] != EDITING_STATE)
+ raise Discourse::InvalidParameters.new(:state)
+ end
- if data[:response_needed]
- messagebus_channel = Presence::PresenceManager.get_messagebus_channel(type, id)
- users ||= Presence::PresenceManager.get_users(type, id)
- payload = json_payload(messagebus_channel, users)
+ 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
+ }
+
+ 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
- render json: payload
- end
-
- def json_payload(channel, users)
- {
- messagebus_channel: channel,
- messagebus_id: MessageBus.last_id(channel),
- users: users.limit(SiteSetting.presence_max_users_shown).map { |u| BasicUserSerializer.new(u, root: false) }
+ 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
}
+
+ if (post_id = permitted_params[:post_id]).present?
+ payload[:post_id] = post_id
+ end
+
+ MessageBus.publish("/presence/#{topic_id}", payload, opts)
+
+ render json: success_json
end
+ private
+
+ def ensure_presence_enabled
+ if !SiteSetting.presence_enabled ||
+ current_user.user_option.hide_profile_and_presence?
+
+ raise Discourse::NotFound
+ end
+ end
+
+ def permitted_params
+ params.permit(:state, :topic_id, :post_id, :is_whisper)
+ end
end
Presence::Engine.routes.draw do
- post '/publish' => 'presences#publish'
+ post '/publish' => 'presences#handle_message'
end
Discourse::Application.routes.append do
diff --git a/plugins/discourse-presence/spec/presence_manager_spec.rb b/plugins/discourse-presence/spec/presence_manager_spec.rb
deleted file mode 100644
index b6cf1155ce4..00000000000
--- a/plugins/discourse-presence/spec/presence_manager_spec.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe ::Presence::PresenceManager do
-
- let(:user1) { Fabricate(:user) }
- let(:user2) { Fabricate(:user) }
- let(:user3) { Fabricate(:user) }
- let(:manager) { ::Presence::PresenceManager }
-
- let(:post1) { Fabricate(:post) }
- let(:post2) { Fabricate(:post) }
-
- after(:each) do
- Discourse.redis.del("presence:topic:#{post1.topic.id}")
- Discourse.redis.del("presence:topic:#{post2.topic.id}")
- Discourse.redis.del("presence:post:#{post1.id}")
- Discourse.redis.del("presence:post:#{post2.id}")
- end
-
- it 'adds, removes and lists users correctly' do
- expect(manager.get_users('post', post1.id).count).to eq(0)
-
- expect(manager.add('post', post1.id, user1.id)).to be true
- expect(manager.add('post', post1.id, user2.id)).to be true
- expect(manager.add('post', post2.id, user3.id)).to be true
-
- expect(manager.get_users('post', post1.id).count).to eq(2)
- expect(manager.get_users('post', post2.id).count).to eq(1)
-
- expect(manager.get_users('post', post1.id)).to contain_exactly(user1, user2)
- expect(manager.get_users('post', post2.id)).to contain_exactly(user3)
-
- expect(manager.remove('post', post1.id, user1.id)).to be true
- expect(manager.get_users('post', post1.id).count).to eq(1)
- expect(manager.get_users('post', post1.id)).to contain_exactly(user2)
- end
-
- it 'publishes correctly' do
- expect(manager.get_users('post', post1.id).count).to eq(0)
-
- manager.add('post', post1.id, user1.id)
- manager.add('post', post1.id, user2.id)
-
- messages = MessageBus.track_publish do
- manager.publish('post', post1.id)
- end
-
- expect(messages.count).to eq (1)
- message = messages.first
-
- expect(message.channel).to eq("/presence/post/#{post1.id}")
-
- expect(message.data["users"].map { |u| u[:id] }).to contain_exactly(user1.id, user2.id)
- end
-
- it 'publishes private message securely' do
- private_post = Fabricate(:private_message_post)
- manager.add('post', private_post.id, user2.id)
-
- messages = MessageBus.track_publish do
- manager.publish('post', private_post.id)
- end
-
- expect(messages.count).to eq (1)
- message = messages.first
-
- expect(message.channel).to eq("/presence/post/#{private_post.id}")
-
- user_ids = User.where('admin or moderator').pluck(:id)
- user_ids += private_post.topic.allowed_users.pluck(:id)
- expect(message.user_ids).to contain_exactly(*user_ids)
- end
-
- it 'publishes private category securely' do
- group = Fabricate(:group)
- category = Fabricate(:private_category, group: group)
- private_topic = Fabricate(:topic, category: category)
-
- manager.add('topic', private_topic.id, user2.id)
-
- messages = MessageBus.track_publish do
- manager.publish('topic', private_topic.id)
- end
-
- expect(messages.count).to eq (1)
- message = messages.first
-
- expect(message.channel).to eq("/presence/topic/#{private_topic.id}")
-
- expect(message.group_ids).to contain_exactly(*private_topic.secure_group_ids)
- end
-
- it 'cleans up correctly' do
- freeze_time Time.zone.now do
- expect(manager.add('post', post1.id, user1.id)).to be true
- expect(manager.cleanup('post', post1.id)).to be false # Nothing to cleanup
- expect(manager.get_users('post', post1.id).count).to eq(1)
- end
-
- # Anything older than 20 seconds should be cleaned up
- freeze_time 30.seconds.from_now do
- expect(manager.cleanup('post', post1.id)).to be true
- expect(manager.get_users('post', post1.id).count).to eq(0)
- 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
index 80547019a73..c13e04fa6c3 100644
--- a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb
+++ b/plugins/discourse-presence/spec/requests/presence_controller_spec.rb
@@ -3,170 +3,442 @@
require 'rails_helper'
describe ::Presence::PresencesController do
- before do
- SiteSetting.presence_enabled = true
- end
+ describe '#handle_message' do
+ context 'when not logged in' do
+ it 'should raise the right error' do
+ post '/presence/publish.json'
- let(:user1) { Fabricate(:user) }
- let(:user2) { Fabricate(:user) }
- let(:user3) { Fabricate(:user) }
+ expect(response.status).to eq(403)
+ end
+ end
- let(:post1) { Fabricate(:post) }
- let(:post2) { Fabricate(:post) }
+ context 'when logged in' do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:user2) { Fabricate(:user) }
+ fab!(:admin) { Fabricate(:admin) }
- let(:manager) { ::Presence::PresenceManager }
+ fab!(:group) do
+ group = Fabricate(:group)
+ group.add(user)
+ group
+ end
- after do
- Discourse.redis.del("presence:topic:#{post1.topic.id}")
- Discourse.redis.del("presence:topic:#{post2.topic.id}")
- Discourse.redis.del("presence:post:#{post1.id}")
- Discourse.redis.del("presence:post:#{post2.id}")
- end
+ fab!(:category) { Fabricate(:private_category, group: group) }
+ fab!(:private_topic) { Fabricate(:topic, category: category) }
+ fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) }
- context 'when not logged in' do
- it 'should raise the right error' do
- post '/presence/publish.json'
- expect(response.status).to eq(403)
+ 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/publish.json'
+
+ expect(response.status).to eq(404)
+ end
+
+ it 'returns the right response when the presence site settings is disabled' do
+ SiteSetting.presence_enabled = false
+
+ post '/presence/publish.json'
+
+ expect(response.status).to eq(404)
+ end
+
+ it 'returns the right response if required params are missing' do
+ post '/presence/publish.json'
+
+ expect(response.status).to eq(400)
+ end
+
+ it 'returns the right response if topic_id is invalid' do
+ post '/presence/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/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/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/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/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/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/#{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/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/#{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/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/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 a staff is editing a whisper' do
+ SiteSetting.enable_whispers = true
+ sign_in(admin)
+
+ messages = MessageBus.track_publish do
+ post '/presence/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/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/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/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/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/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/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 'publises the right message when closing composer in public topic' do
+ messages = MessageBus.track_publish do
+ post '/presence/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 'publises the right message when closing composer in private topic' do
+ messages = MessageBus.track_publish do
+ post '/presence/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 'publises 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/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
-
- context 'when logged in' do
- before do
- sign_in(user1)
- end
-
- it "doesn't produce an error" do
- expect { post '/presence/publish.json' }.not_to raise_error
- end
-
- it "does not publish for users with disabled presence features" do
- user1.user_option.update_column(:hide_profile_and_presence, true)
- post '/presence/publish.json'
- expect(response.code).to eq("404")
- end
-
- it "uses guardian to secure endpoint" do
- private_post = Fabricate(:private_message_post)
-
- post '/presence/publish.json', params: {
- current: { action: 'edit', post_id: private_post.id }
- }
-
- expect(response.code.to_i).to eq(403)
-
- group = Fabricate(:group)
- category = Fabricate(:private_category, group: group)
- private_topic = Fabricate(:topic, category: category)
-
- post '/presence/publish.json', params: {
- current: { action: 'edit', topic_id: private_topic.id }
- }
-
- expect(response.code.to_i).to eq(403)
- end
-
- it "returns a response when requested" do
- messages = MessageBus.track_publish do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }, response_needed: true
- }
- end
-
- expect(messages.count).to eq(1)
-
- data = JSON.parse(response.body)
-
- expect(data['messagebus_channel']).to eq("/presence/post/#{post1.id}")
- expect(data['messagebus_id']).to eq(MessageBus.last_id("/presence/post/#{post1.id}"))
- expect(data['users'][0]["id"]).to eq(user1.id)
- end
-
- it "doesn't return a response when not requested" do
- messages = MessageBus.track_publish do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
- end
-
- expect(messages.count).to eq(1)
-
- data = JSON.parse(response.body)
- expect(data).to eq({})
- end
-
- it "does send duplicate messagebus messages" do
- messages = MessageBus.track_publish do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
- end
-
- expect(messages.count).to eq(1)
-
- messages = MessageBus.track_publish do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
- end
-
- # we do this cause we also publish time
- expect(messages.count).to eq(1)
- end
-
- it "clears 'previous' state when supplied" do
- messages = MessageBus.track_publish do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
-
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post2.id },
- previous: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
- end
-
- expect(messages.count).to eq(3)
- end
-
- it 'cleans up old users when requested' do
- freeze_time Time.zone.now do
- manager.add('topic', post1.topic.id, user2.id)
- end
-
- # Anything older than 20 seconds should be cleaned up
- freeze_time 30.seconds.from_now do
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'reply', topic_id: post1.topic.id }, response_needed: true
- }
-
- data = JSON.parse(response.body)
-
- expect(data['users'].length).to eq(1)
- end
-
- end
-
- describe 'when post has been deleted' do
- it 'should return an empty response' do
- post1.destroy!
-
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
-
- expect(response.status).to eq(200)
- expect(JSON.parse(response.body)).to eq({})
-
- post '/presence/publish.json', params: {
- current: { compose_state: 'open', action: 'edit', post_id: post2.id },
- previous: { compose_state: 'open', action: 'edit', post_id: post1.id }
- }
-
- expect(response.status).to eq(200)
- expect(JSON.parse(response.body)).to eq({})
- end
- end
-
- end
-
end