mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
DEV: Update discourse-presence plugin to use new PresenceChannel system (#14519)
This removes all custom controllers and redis/messagebus logic from discourse-presence, and replaces it with core's new PresenceChannel system.
All functionality should be retained. This implementation should scale much better to large numbers of users, reduce the number of HTTP requests made by clients, and reduce the volume of messages on the MessageBus.
For more information on PresenceChannel, see 31db8352
This commit is contained in:
parent
80ec6f09d3
commit
b57b079ff2
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -1,5 +0,0 @@
|
|||
export default {
|
||||
shouldRender(_, component) {
|
||||
return component.siteSettings.presence_enabled;
|
||||
},
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
{{!-- Note: the topic-above-footer-buttons outlet is only rendered for logged-in users --}}
|
||||
{{topic-presence-display topic=model}}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export default {
|
||||
shouldRender(_, component) {
|
||||
return component.siteSettings.presence_enabled;
|
||||
},
|
||||
};
|
|
@ -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
|
||||
|
|
193
plugins/discourse-presence/spec/integration/presence_spec.rb
Normal file
193
plugins/discourse-presence/spec/integration/presence_spec.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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");
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user