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:
David Taylor 2021-10-21 12:42:46 +01:00 committed by GitHub
parent 80ec6f09d3
commit b57b079ff2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 673 additions and 1071 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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();
},
});

View File

@ -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();
},
});

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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;

View File

@ -1,5 +0,0 @@
export default {
shouldRender(_, component) {
return component.siteSettings.presence_enabled;
},
};

View File

@ -1 +1,2 @@
{{!-- Note: the topic-above-footer-buttons outlet is only rendered for logged-in users --}}
{{topic-presence-display topic=model}}

View File

@ -1,5 +0,0 @@
export default {
shouldRender(_, component) {
return component.siteSettings.presence_enabled;
},
};

View File

@ -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"]
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]]
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]
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 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)
)
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
# 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
opts[:user_ids].uniq!
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)
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.
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.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
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
if !topic.private_message? && SiteSetting.trusted_users_can_edit_others?
config.allowed_group_ids << Group::AUTO_GROUPS[:trust_level_4]
end
MessageBus.publish("/presence-plugin/#{topic_id}", payload, opts)
render json: success_json
if SiteSetting.enable_category_group_moderation? && group_id = topic.category&.reviewable_by_group_id
config.allowed_group_ids << group_id
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
config
end
rescue ActiveRecord::RecordNotFound
nil
end
def permitted_params
params.permit(:state, :topic_id, :post_id, :is_whisper, :staff_only)
end
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

View 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

View File

@ -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

View File

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