FEATURE: Redesign discourse-presence to track state on the client side. (#9487)

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.

diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6
index 1e78297..e6a3660 100644
--- a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6
+++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6
@@ -1,12 +1,11 @@
-import { cancel, debounce, once } from "@ember/runloop";
 import Component from "@ember/component";
-import { equal, gt } from "@ember/object/computed";
-import { Promise } from "rsvp";
-import { ajax } from "discourse/lib/ajax";
-import computed, { observes, on } from "discourse-common/utils/decorators";
-
-export const keepAliveDuration = 10000;
-export const bufferTime = 3000;
+import { cancel } from "@ember/runloop";
+import { equal, gt, readOnly } from "@ember/object/computed";
+import discourseComputed, {
+  observes,
+  on
+} from "discourse-common/utils/decorators";
+import { REPLYING, CLOSED, EDITING } from "../lib/presence-manager";
 
 export default Component.extend({
   // Passed in variables
@@ -15,115 +14,67 @@ export default Component.extend({
   topic: null,
   reply: null,
   title: null,
+  isWhispering: null,
 
-  // Internal variables
-  previousState: null,
-  currentState: null,
-  presenceUsers: null,
-  channel: null,
-
+  presenceManager: readOnly("topic.presenceManager"),
+  users: readOnly("presenceManager.users"),
+  editingUsers: readOnly("presenceManager.editingUsers"),
   isReply: equal("action", "reply"),
-  shouldDisplay: gt("users.length", 0),
 
   @on("didInsertElement")
-  composerOpened() {
-    this._lastPublish = new Date();
-    once(this, "updateState");
-  },
-
-  @observes("action", "post.id", "topic.id")
-  composerStateChanged() {
-    once(this, "updateState");
+  subscribe() {
+    this.presenceManager && this.presenceManager.subscribe();
   },
 
-  @observes("reply", "title")
-  typing() {
-    if (new Date() - this._lastPublish > keepAliveDuration) {
-      this.publish({ current: this.currentState });
+  @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;
     }
   },
 
-  @on("willDestroyElement")
-  composerClosing() {
-    this.publish({ previous: this.currentState });
-    cancel(this._pingTimer);
-    cancel(this._clearTimer);
-  },
+  shouldDisplay: gt("presenceUsers.length", 0),
 
-  updateState() {
-    let state = null;
-    const action = this.action;
+  @observes("reply", "title")
+  typing() {
+    if (this.presenceManager) {
+      const postId = this.get("post.id");
 
-    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");
+      this._throttle = this.presenceManager.throttlePublish(
+        postId ? EDITING : REPLYING,
+        this.whisper,
+        postId
+      );
     }
-
-    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
-      );
-    });
+  @observes("whisper")
+  cancelThrottle() {
+    this._cancelThrottle();
   },
 
-  clear() {
-    if (!this.isDestroyed) this.set("presenceUsers", []);
+  @observes("post.id")
+  stopEditing() {
+    if (this.presenceManager && !this.get("post.id")) {
+      this.presenceManager.publish(CLOSED, this.whisper);
+    }
   },
 
-  publish(data) {
-    this._lastPublish = new Date();
-
-    // Don't publish presence if disabled
-    if (this.currentUser.hide_profile_and_presence) {
-      return Promise.resolve();
+  @on("willDestroyElement")
+  composerClosing() {
+    if (this.presenceManager) {
+      this._cancelThrottle();
+      this.presenceManager.publish(CLOSED, this.whisper);
     }
-
-    return ajax("/presence/publish", { type: "POST", data });
   },
 
-  @computed("presenceUsers", "currentUser.id")
-  users(users, currentUserId) {
-    return (users || []).filter(user => user.id !== currentUserId);
+  _cancelThrottle() {
+    cancel(this._throttle);
   }
 });
diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6
index 1089246..9cfb46f 100644
--- a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6
+++ b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6
@@ -1,59 +1,21 @@
-import { cancel, debounce } from "@ember/runloop";
 import Component from "@ember/component";
-import { gt } from "@ember/object/computed";
-import computed, { on } from "discourse-common/utils/decorators";
-import {
-  keepAliveDuration,
-  bufferTime
-} from "discourse/plugins/discourse-presence/discourse/components/composer-presence-display";
-
-const MB_GET_LAST_MESSAGE = -2;
+import { gt, readOnly } from "@ember/object/computed";
+import { on } from "discourse-common/utils/decorators";
 
 export default Component.extend({
-  topicId: null,
-  presenceUsers: null,
+  topic: null,
 
+  presenceManager: readOnly("topic.presenceManager"),
+  users: readOnly("presenceManager.users"),
   shouldDisplay: gt("users.length", 0),
 
-  clear() {
-    if (!this.isDestroyed) this.set("presenceUsers", []);
-  },
-
   @on("didInsertElement")
-  _inserted() {
-    this.clear();
-
-    this.messageBus.subscribe(
-      this.channel,
-      message => {
-        if (!this.isDestroyed) this.set("presenceUsers", message.users);
-        this._clearTimer = debounce(
-          this,
-          "clear",
-          keepAliveDuration + bufferTime
-        );
-      },
-      MB_GET_LAST_MESSAGE
-    );
+  subscribe() {
+    this.get("presenceManager").subscribe();
   },
 
   @on("willDestroyElement")
   _destroyed() {
-    cancel(this._clearTimer);
-    this.messageBus.unsubscribe(this.channel);
-  },
-
-  @computed("topicId")
-  channel(topicId) {
-    return `/presence/topic/${topicId}`;
-  },
-
-  @computed("presenceUsers", "currentUser.{id,ignored_users}")
-  users(users, currentUser) {
-    const ignoredUsers = currentUser.ignored_users || [];
-    return (users || []).filter(
-      user =>
-        user.id !== currentUser.id && !ignoredUsers.includes(user.username)
-    );
+    this.get("presenceManager").unsubscribe();
   }
 });
diff --git a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6
new file mode 100644
index 0000000..fe5c1bd
--- /dev/null
+++ b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence-manager.js.es6
@@ -0,0 +1,201 @@

[... diff too long, it was truncated ...]

GitHub sha: 301a0fa5

This commit appears in #9487 which was approved by davidtaylorhq. It was merged by SamSaffron.