mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Redesign discourse-presence to track state on the client side. (#9487)
Before this commit, the presence state of users were stored on the server side and any updates to the state meant we had to publish the entire state to the clients. Also, the way the state of users were stored on the server side meant we didn't have a way to differentiate between replying users and whispering users. In this redesign, we decided to move the tracking of users state to the client side and have the server publish client events instead. As a result of this change, we're able to remove the number of opened connections needed to track presence and also reduce the payload that is sent for each event. At the same time, we've also improved on the restrictions when publishing message_bus messages. Users that do not have permission to see certain events will not receive messages for those events.
This commit is contained in:
committed by
GitHub
parent
5503eba924
commit
301a0fa54e
@@ -1,12 +1,11 @@
|
||||
import { cancel, debounce, once } from "@ember/runloop";
|
||||
import Component from "@ember/component";
|
||||
import { equal, gt } from "@ember/object/computed";
|
||||
import { Promise } from "rsvp";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import computed, { observes, on } from "discourse-common/utils/decorators";
|
||||
|
||||
export const keepAliveDuration = 10000;
|
||||
export const bufferTime = 3000;
|
||||
import { cancel } from "@ember/runloop";
|
||||
import { equal, gt, readOnly } from "@ember/object/computed";
|
||||
import discourseComputed, {
|
||||
observes,
|
||||
on
|
||||
} from "discourse-common/utils/decorators";
|
||||
import { REPLYING, CLOSED, EDITING } from "../lib/presence-manager";
|
||||
|
||||
export default Component.extend({
|
||||
// Passed in variables
|
||||
@@ -15,115 +14,67 @@ export default Component.extend({
|
||||
topic: null,
|
||||
reply: null,
|
||||
title: null,
|
||||
isWhispering: null,
|
||||
|
||||
// Internal variables
|
||||
previousState: null,
|
||||
currentState: null,
|
||||
presenceUsers: null,
|
||||
channel: null,
|
||||
|
||||
presenceManager: readOnly("topic.presenceManager"),
|
||||
users: readOnly("presenceManager.users"),
|
||||
editingUsers: readOnly("presenceManager.editingUsers"),
|
||||
isReply: equal("action", "reply"),
|
||||
shouldDisplay: gt("users.length", 0),
|
||||
|
||||
@on("didInsertElement")
|
||||
composerOpened() {
|
||||
this._lastPublish = new Date();
|
||||
once(this, "updateState");
|
||||
subscribe() {
|
||||
this.presenceManager && this.presenceManager.subscribe();
|
||||
},
|
||||
|
||||
@observes("action", "post.id", "topic.id")
|
||||
composerStateChanged() {
|
||||
once(this, "updateState");
|
||||
@discourseComputed(
|
||||
"post.id",
|
||||
"editingUsers.@each.last_seen",
|
||||
"users.@each.last_seen"
|
||||
)
|
||||
presenceUsers(postId, editingUsers, users) {
|
||||
if (postId) {
|
||||
return editingUsers.filterBy("post_id", postId);
|
||||
} else {
|
||||
return users;
|
||||
}
|
||||
},
|
||||
|
||||
shouldDisplay: gt("presenceUsers.length", 0),
|
||||
|
||||
@observes("reply", "title")
|
||||
typing() {
|
||||
if (new Date() - this._lastPublish > keepAliveDuration) {
|
||||
this.publish({ current: this.currentState });
|
||||
if (this.presenceManager) {
|
||||
const postId = this.get("post.id");
|
||||
|
||||
this._throttle = this.presenceManager.throttlePublish(
|
||||
postId ? EDITING : REPLYING,
|
||||
this.whisper,
|
||||
postId
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@observes("whisper")
|
||||
cancelThrottle() {
|
||||
this._cancelThrottle();
|
||||
},
|
||||
|
||||
@observes("post.id")
|
||||
stopEditing() {
|
||||
if (this.presenceManager && !this.get("post.id")) {
|
||||
this.presenceManager.publish(CLOSED, this.whisper);
|
||||
}
|
||||
},
|
||||
|
||||
@on("willDestroyElement")
|
||||
composerClosing() {
|
||||
this.publish({ previous: this.currentState });
|
||||
cancel(this._pingTimer);
|
||||
cancel(this._clearTimer);
|
||||
},
|
||||
|
||||
updateState() {
|
||||
let state = null;
|
||||
const action = this.action;
|
||||
|
||||
if (action === "reply" || action === "edit") {
|
||||
state = { action };
|
||||
if (action === "reply") state.topic_id = this.get("topic.id");
|
||||
if (action === "edit") state.post_id = this.get("post.id");
|
||||
if (this.presenceManager) {
|
||||
this._cancelThrottle();
|
||||
this.presenceManager.publish(CLOSED, this.whisper);
|
||||
}
|
||||
|
||||
this.set("previousState", this.currentState);
|
||||
this.set("currentState", state);
|
||||
},
|
||||
|
||||
@observes("currentState")
|
||||
currentStateChanged() {
|
||||
if (this.channel) {
|
||||
this.messageBus.unsubscribe(this.channel);
|
||||
this.set("channel", null);
|
||||
}
|
||||
|
||||
this.clear();
|
||||
|
||||
if (!["reply", "edit"].includes(this.action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.publish({
|
||||
response_needed: true,
|
||||
previous: this.previousState,
|
||||
current: this.currentState
|
||||
}).then(r => {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.set("presenceUsers", r.users);
|
||||
this.set("channel", r.messagebus_channel);
|
||||
|
||||
if (!r.messagebus_channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageBus.subscribe(
|
||||
r.messagebus_channel,
|
||||
message => {
|
||||
if (!this.isDestroyed) this.set("presenceUsers", message.users);
|
||||
this._clearTimer = debounce(
|
||||
this,
|
||||
"clear",
|
||||
keepAliveDuration + bufferTime
|
||||
);
|
||||
},
|
||||
r.messagebus_id
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
clear() {
|
||||
if (!this.isDestroyed) this.set("presenceUsers", []);
|
||||
},
|
||||
|
||||
publish(data) {
|
||||
this._lastPublish = new Date();
|
||||
|
||||
// Don't publish presence if disabled
|
||||
if (this.currentUser.hide_profile_and_presence) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return ajax("/presence/publish", { type: "POST", data });
|
||||
},
|
||||
|
||||
@computed("presenceUsers", "currentUser.id")
|
||||
users(users, currentUserId) {
|
||||
return (users || []).filter(user => user.id !== currentUserId);
|
||||
_cancelThrottle() {
|
||||
cancel(this._throttle);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,59 +1,21 @@
|
||||
import { cancel, debounce } from "@ember/runloop";
|
||||
import Component from "@ember/component";
|
||||
import { gt } from "@ember/object/computed";
|
||||
import computed, { on } from "discourse-common/utils/decorators";
|
||||
import {
|
||||
keepAliveDuration,
|
||||
bufferTime
|
||||
} from "discourse/plugins/discourse-presence/discourse/components/composer-presence-display";
|
||||
|
||||
const MB_GET_LAST_MESSAGE = -2;
|
||||
import { gt, readOnly } from "@ember/object/computed";
|
||||
import { on } from "discourse-common/utils/decorators";
|
||||
|
||||
export default Component.extend({
|
||||
topicId: null,
|
||||
presenceUsers: null,
|
||||
topic: null,
|
||||
|
||||
presenceManager: readOnly("topic.presenceManager"),
|
||||
users: readOnly("presenceManager.users"),
|
||||
shouldDisplay: gt("users.length", 0),
|
||||
|
||||
clear() {
|
||||
if (!this.isDestroyed) this.set("presenceUsers", []);
|
||||
},
|
||||
|
||||
@on("didInsertElement")
|
||||
_inserted() {
|
||||
this.clear();
|
||||
|
||||
this.messageBus.subscribe(
|
||||
this.channel,
|
||||
message => {
|
||||
if (!this.isDestroyed) this.set("presenceUsers", message.users);
|
||||
this._clearTimer = debounce(
|
||||
this,
|
||||
"clear",
|
||||
keepAliveDuration + bufferTime
|
||||
);
|
||||
},
|
||||
MB_GET_LAST_MESSAGE
|
||||
);
|
||||
subscribe() {
|
||||
this.get("presenceManager").subscribe();
|
||||
},
|
||||
|
||||
@on("willDestroyElement")
|
||||
_destroyed() {
|
||||
cancel(this._clearTimer);
|
||||
this.messageBus.unsubscribe(this.channel);
|
||||
},
|
||||
|
||||
@computed("topicId")
|
||||
channel(topicId) {
|
||||
return `/presence/topic/${topicId}`;
|
||||
},
|
||||
|
||||
@computed("presenceUsers", "currentUser.{id,ignored_users}")
|
||||
users(users, currentUser) {
|
||||
const ignoredUsers = currentUser.ignored_users || [];
|
||||
return (users || []).filter(
|
||||
user =>
|
||||
user.id !== currentUser.id && !ignoredUsers.includes(user.username)
|
||||
);
|
||||
this.get("presenceManager").unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import EmberObject from "@ember/object";
|
||||
import { cancel, later, throttle } from "@ember/runloop";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
// The durations chosen here determines the accuracy of the presence feature and
|
||||
// is tied closely with the server side implementation. Decreasing the duration
|
||||
// to increase the accuracy will come at the expense of having to more network
|
||||
// calls to publish the client's state.
|
||||
//
|
||||
// Logic walk through of our heuristic implementation:
|
||||
// - When client A is typing, a message is published every KEEP_ALIVE_DURATION_SECONDS.
|
||||
// - Client B receives the message and stores each user in an array and marks
|
||||
// the user with a client-side timestamp of when the user was seen.
|
||||
// - If client A continues to type, client B will continue to receive messages to
|
||||
// update the client-side timestamp of when client A was last seen.
|
||||
// - If client A disconnects or becomes inactive, the state of client A will be
|
||||
// cleaned up on client B by a scheduler that runs every TIMER_INTERVAL_MILLISECONDS
|
||||
const KEEP_ALIVE_DURATION_SECONDS = 10;
|
||||
const BUFFER_DURATION_SECONDS = KEEP_ALIVE_DURATION_SECONDS + 2;
|
||||
|
||||
const MESSAGE_BUS_LAST_ID = 0;
|
||||
const TIMER_INTERVAL_MILLISECONDS = 2000;
|
||||
|
||||
export const REPLYING = "replying";
|
||||
export const EDITING = "editing";
|
||||
export const CLOSED = "closed";
|
||||
|
||||
const PresenceManager = EmberObject.extend({
|
||||
users: null,
|
||||
editingUsers: null,
|
||||
subscribed: null,
|
||||
topic: null,
|
||||
currentUser: null,
|
||||
messageBus: null,
|
||||
siteSettings: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.setProperties({
|
||||
users: [],
|
||||
editingUsers: [],
|
||||
subscribed: false
|
||||
});
|
||||
},
|
||||
|
||||
subscribe() {
|
||||
if (this.subscribed) return;
|
||||
|
||||
this.messageBus.subscribe(
|
||||
this.channel,
|
||||
message => {
|
||||
const { user, state } = message;
|
||||
if (this.get("currentUser.id") === user.id) return;
|
||||
|
||||
switch (state) {
|
||||
case REPLYING:
|
||||
this._appendUser(this.users, user);
|
||||
break;
|
||||
case EDITING:
|
||||
this._appendUser(this.editingUsers, user, {
|
||||
post_id: parseInt(message.post_id, 10)
|
||||
});
|
||||
break;
|
||||
case CLOSED:
|
||||
this._removeUser(user);
|
||||
break;
|
||||
}
|
||||
},
|
||||
MESSAGE_BUS_LAST_ID
|
||||
);
|
||||
|
||||
this.set("subscribed", true);
|
||||
},
|
||||
|
||||
unsubscribe() {
|
||||
this.messageBus.unsubscribe(this.channel);
|
||||
this._stopTimer();
|
||||
this.set("subscribed", false);
|
||||
},
|
||||
|
||||
@discourseComputed("topic.id")
|
||||
channel(topicId) {
|
||||
return `/presence/${topicId}`;
|
||||
},
|
||||
|
||||
throttlePublish(state, whisper, postId) {
|
||||
return throttle(
|
||||
this,
|
||||
this.publish,
|
||||
state,
|
||||
whisper,
|
||||
postId,
|
||||
KEEP_ALIVE_DURATION_SECONDS * 1000
|
||||
);
|
||||
},
|
||||
|
||||
publish(state, whisper, postId) {
|
||||
const data = {
|
||||
state,
|
||||
topic_id: this.get("topic.id")
|
||||
};
|
||||
|
||||
if (whisper) {
|
||||
data.is_whisper = 1;
|
||||
}
|
||||
|
||||
if (postId) {
|
||||
data.post_id = postId;
|
||||
}
|
||||
|
||||
return ajax("/presence/publish", {
|
||||
type: "POST",
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
_removeUser(user) {
|
||||
[this.users, this.editingUsers].forEach(users => {
|
||||
const existingUser = users.findBy("id", user.id);
|
||||
if (existingUser) users.removeObject(existingUser);
|
||||
});
|
||||
},
|
||||
|
||||
_cleanUpUsers() {
|
||||
[this.users, this.editingUsers].forEach(users => {
|
||||
const staleUsers = [];
|
||||
|
||||
users.forEach(user => {
|
||||
if (user.last_seen <= Date.now() - BUFFER_DURATION_SECONDS * 1000) {
|
||||
staleUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
users.removeObjects(staleUsers);
|
||||
});
|
||||
|
||||
return this.users.length === 0 && this.editingUsers.length === 0;
|
||||
},
|
||||
|
||||
_appendUser(users, user, attrs) {
|
||||
let existingUser;
|
||||
let usersLength = 0;
|
||||
|
||||
users.forEach(u => {
|
||||
if (u.id === user.id) {
|
||||
existingUser = u;
|
||||
}
|
||||
|
||||
if (attrs && attrs.post_id) {
|
||||
if (u.post_id === attrs.post_id) usersLength++;
|
||||
} else {
|
||||
usersLength++;
|
||||
}
|
||||
});
|
||||
|
||||
const props = attrs || {};
|
||||
props.last_seen = Date.now();
|
||||
|
||||
if (existingUser) {
|
||||
existingUser.setProperties(props);
|
||||
} else {
|
||||
const limit = this.get("siteSettings.presence_max_users_shown");
|
||||
|
||||
if (usersLength < limit) {
|
||||
users.pushObject(EmberObject.create(Object.assign(user, props)));
|
||||
}
|
||||
}
|
||||
|
||||
this._startTimer(() => {
|
||||
this._cleanUpUsers();
|
||||
});
|
||||
},
|
||||
|
||||
_scheduleTimer(callback) {
|
||||
return later(
|
||||
this,
|
||||
() => {
|
||||
const stop = callback();
|
||||
|
||||
if (!stop) {
|
||||
this.set("_timer", this._scheduleTimer(callback));
|
||||
}
|
||||
},
|
||||
TIMER_INTERVAL_MILLISECONDS
|
||||
);
|
||||
},
|
||||
|
||||
_stopTimer() {
|
||||
cancel(this._timer);
|
||||
},
|
||||
|
||||
_startTimer(callback) {
|
||||
if (!this._timer) {
|
||||
this.set("_timer", this._scheduleTimer(callback));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default PresenceManager;
|
||||
@@ -1,7 +1,7 @@
|
||||
{{#if shouldDisplay}}
|
||||
<div class="presence-users">
|
||||
<div class="presence-avatars">
|
||||
{{#each users as |user|}}
|
||||
{{#each presenceUsers as |user|}}
|
||||
{{avatar user avatarTemplatePath="avatar_template" usernamePath="username" imageSize="small"}}
|
||||
{{/each}}
|
||||
</div>
|
||||
@@ -16,4 +16,4 @@
|
||||
--}}<span class="wave"><span class="dot">.</span><span class="dot">.</span><span class="dot">.</span>
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
topic=model.topic
|
||||
reply=model.reply
|
||||
title=model.title
|
||||
whisper=model.whisper
|
||||
}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{topic-presence-display topicId=model.id}}
|
||||
{{topic-presence-display topic=model}}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import PresenceManager from "../discourse/lib/presence-manager";
|
||||
|
||||
function initializeDiscoursePresence(api) {
|
||||
const currentUser = api.getCurrentUser();
|
||||
const siteSettings = api.container.lookup("site-settings:main");
|
||||
|
||||
if (currentUser && !currentUser.hide_profile_and_presence) {
|
||||
api.modifyClass("model:topic", {
|
||||
presenceManager: null
|
||||
});
|
||||
|
||||
api.modifyClass("route:topic-from-params", {
|
||||
setupController() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.modelFor("topic").set(
|
||||
"presenceManager",
|
||||
PresenceManager.create({
|
||||
topic: this.modelFor("topic"),
|
||||
currentUser,
|
||||
messageBus: api.container.lookup("message-bus:main"),
|
||||
siteSettings
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-presence",
|
||||
after: "message-bus",
|
||||
|
||||
initialize(container) {
|
||||
const siteSettings = container.lookup("site-settings:main");
|
||||
|
||||
if (siteSettings.presence_enabled) {
|
||||
withPluginApi("0.8.40", initializeDiscoursePresence);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user