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
15 changed files with 673 additions and 1071 deletions

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;
},
};