DEV: Introduce PresenceChannel API for core and plugin use

DEV: Introduce PresenceChannel API for core and plugin use

PresenceChannel aims to be a generic system for allow the server, and end-users, to track the number and identity of users performing a specific task on the site. For example, it might be used to track who is currently ‘replying’ to a specific topic, editing a specific wiki post, etc.

A few key pieces of information about the system:

  • PresenceChannels are identified by a name of the format /prefix/blah, where prefix has been configured by some core/plugin implementation, and blah can be any string the implementation wants to use.
  • Presence is a boolean thing - each user is either present, or not present. If a user has multiple clients ‘present’ in a channel, they will be deduplicated so that the user is only counted once
  • Developers can configure the existence and configuration of channels ‘just in time’ using a callback. The result of this is cached for 2 minutes.
  • Configuration of a channel can specify permissions in a similar way to MessageBus (public boolean, a list of allowed_user_ids, and a list of allowed_group_ids). A channel can also be placed in ‘count_only’ mode, where the identity of present users is not revealed to end-users.
  • The backend implementation uses redis lua scripts, and is designed to scale well. In the future, hard limits may be introduced on the maximum number of users that can be present in a channel.
  • Clients can enter/leave at will. If a client has not marked itself ‘present’ in the last 60 seconds, they will automatically ‘leave’ the channel. The JS implementation takes care of this regular check-in.
  • On the client-side, PresenceChannel instances can be fetched from the presence ember service. Each PresenceChannel can be used entered/left/subscribed/unsubscribed, and the service will automatically deduplicate information before interacting with the server.
  • When a client joins a PresenceChannel, the JS implementation will automatically make a GET request for the current channel state. To avoid this, the channel state can be serialized into one of your existing endpoints, and then passed to the subscribe method on the channel.
  • The PresenceChannel JS object is an ember object. The users and count property can be used directly in ember templates, and in computed properties.
  • It is important to make sure that you unsubscribe() and leave() any PresenceChannel objects after use

An example implementation may look something like this. On the server:

register_presence_channel_prefix("site") do |channel|
  next nil unless channel == "/site/online"
  PresenceChannel::Config.new(public: true)
end

And on the client, a component could be implemented like this:

import Component from "@ember/component";
import { inject as service } from "@ember/service";

export default Component.extend({
  presence: service(),
  init() {
    this._super(...arguments);
    this.set("presenceChannel", this.presence.getChannel("/site/online"));
  },
  didInsertElement() {
    this.presenceChannel.enter();
    this.presenceChannel.subscribe();
  },
  willDestroyElement() {
    this.presenceChannel.leave();
    this.presenceChannel.unsubscribe();
  },
});

With this template:

Online: {{presenceChannel.count}}
<ul>
  {{#each presenceChannel.users as |user|}} 
    <li>{{avatar user imageSize="tiny"}} {{user.username}}</li>
  {{/each}}
</ul>
diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js
new file mode 100644
index 0000000..325fc20
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/services/presence.js
@@ -0,0 +1,470 @@
+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, throttle } from "@ember/runloop";
+import Session from "discourse/models/session";
+import { Promise } from "rsvp";
+import { isTesting } from "discourse-common/config/environment";
+import User from "discourse/models/user";
+
+const PRESENCE_INTERVAL_S = 30;
+const PRESENCE_DEBOUNCE_MS = isTesting() ? 0 : 500;
+const PRESENCE_THROTTLE_MS = isTesting() ? 0 : 5000;
+
+function createPromiseProxy() {
+  const promiseProxy = {};
+  promiseProxy.promise = new Promise((resolve, reject) => {
+    promiseProxy.resolve = resolve;
+    promiseProxy.reject = reject;
+  });
+  return promiseProxy;
+}
+
+export class PresenceChannelNotFound extends Error {}
+
+// Instances of this class are handed out to consumers. They act as
+// convenient proxies to the PresenceService and PresenceServiceState
+class PresenceChannel extends EmberObject {
+  init({ name, presenceService }) {
+    super.init(...arguments);
+    this.name = name;
+    this.presenceService = presenceService;
+    defineProperty(
+      this,
+      "_presenceState",
+      readOnly(`presenceService._presenceChannelStates.${name}`)
+    );
+
+    this.set("present", false);
+    this.set("subscribed", false);
+  }
+
+  // Mark the current user as 'present' in this channel
+  async enter() {
+    await this.presenceService._enter(this);
+    this.set("present", true);
+  }
+
+  // Mark the current user as leaving this channel
+  async leave() {
+    await this.presenceService._leave(this);
+    this.set("present", false);
+  }
+
+  async subscribe(initialData = null) {
+    if (this.subscribed) {
+      return;
+    }
+    await this.presenceService._subscribe(this, initialData);
+    this.set("subscribed", true);
+  }
+
+  async unsubscribe() {
+    if (!this.subscribed) {
+      return;
+    }
+    await this.presenceService._unsubscribe(this);
+    this.set("subscribed", false);
+  }
+
+  @computed("_presenceState.users", "subscribed")
+  get users() {
+    if (!this.subscribed) {
+      return;
+    }
+    return this._presenceState.users;
+  }
+
+  @computed("_presenceState.count", "subscribed")
+  get count() {
+    if (!this.subscribed) {
+      return;
+    }
+    return this._presenceState.count;
+  }
+
+  @computed("_presenceState.count", "subscribed")
+  get countOnly() {
+    if (!this.subscribed) {
+      return;
+    }
+    return this._presenceState.countOnly;
+  }
+}
+
+class PresenceChannelState extends EmberObject {
+  init({ name, presenceService }) {
+    super.init(...arguments);
+    this.name = name;
+    this.set("users", null);
+    this.set("count", null);
+    this.set("countOnly", null);
+    this.presenceService = presenceService;
+  }
+
+  // Is this PresenceChannel object currently subscribed to updates
+  // from the server.
+  @computed("_subscribedCallback")
+  get subscribed() {
+    return !!this._subscribedCallback;
+  }
+
+  // Subscribe to server-side updates about the channel
+  // Ideally, pass an initialData object with serialized PresenceChannel::State
+  // data from the server (serialized via PresenceChannelStateSerializer).
+  //
+  // If initialData is not supplied, an AJAX request will be made for the information.
+  async subscribe(initialData = null) {
+    if (this.subscribed) {
+      return;
+    }
+
+    if (!initialData) {
+      try {
+        initialData = await ajax("/presence/get", {
+          data: {
+            channel: this.name,
+          },
+        });
+      } catch (e) {
+        if (e.jqXHR?.status === 404) {
+          throw new PresenceChannelNotFound(
+            `PresenceChannel '${this.name}' not found`
+          );
+        } else {
+          throw e;
+        }
+      }
+    }
+
+    this.set("count", initialData.count);
+    if (initialData.users) {
+      this.set("users", initialData.users);
+      this.set("countOnly", false);
+    } else {
+      this.set("users", null);
+      this.set("countOnly", true);
+    }
+
+    this.lastSeenId = initialData.last_message_id;
+
+    let callback = (data, global_id, message_id) => {
+      this._processMessage(data, global_id, message_id);
+    };
+    this.presenceService.messageBus.subscribe(
+      `/presence${this.name}`,
+      callback,
+      this.lastSeenId
+    );
+
+    this.set("_subscribedCallback", callback);
+  }
+
+  // Stop subscribing to updates from the server about this channel
+  unsubscribe() {
+    if (this.subscribed) {
+      this.presenceService.messageBus.unsubscribe(
+        `/presence${this.name}`,
+        this._subscribedCallback
+      );
+      this.set("_subscribedCallback", null);
+      this.set("users", null);
+      this.set("count", null);
+    }
+  }
+
+  async _resubscribe() {
+    this.unsubscribe();
+    // Stored at object level for tests to hook in
+    this._resubscribePromise = this.subscribe();
+    await this._resubscribePromise;
+    delete this._resubscribePromise;
+  }
+
+  async _processMessage(data, global_id, message_id) {
+    if (message_id !== this.lastSeenId + 1) {
+      // eslint-disable-next-line no-console
+      console.log(
+        `PresenceChannel '${
+          this.name
+        }' dropped message (received ${message_id}, expecting ${
+          this.lastSeenId + 1
+        }), resyncing...`
+      );
+
+      await this._resubscribe();
+      return;
+    } else {
+      this.lastSeenId = message_id;
+    }
+
+    if (this.countOnly && data.count_delta !== undefined) {
+      this.set("count", this.count + data.count_delta);
+    } else if (
+      !this.countOnly &&
+      (data.entering_users || data.leaving_user_ids)
+    ) {
+      if (data.entering_users) {
+        const users = data.entering_users.map((u) => User.create(u));
+        this.users.addObjects(users);
+      }
+      if (data.leaving_user_ids) {
+        const leavingIds = new Set(data.leaving_user_ids);
+        const toRemove = this.users.filter((u) => leavingIds.has(u.id));
+        this.users.removeObjects(toRemove);
+      }
+      this.set("count", this.users.length);
+    } else {
+      // Unexpected message
+      await this._resubscribe();
+      return;
+    }
+  }
+}
+
+export default class PresenceService extends Service {
+  init() {
+    super.init(...arguments);
+    this._presentChannels = new Set();
+    this._queuedEvents = [];
+    this._presenceChannelStates = EmberObject.create();
+    this._presentProxies = {};
+    this._subscribedProxies = {};
+    window.addEventListener("beforeunload", () => {
+      this._beaconLeaveAll();
+    });
+  }
+
+  // Get a PresenceChannel object representing a single channel
+  getChannel(channelName) {
+    return PresenceChannel.create({
+      name: channelName,
+      presenceService: this,
+    });
+  }
+
+  _addPresent(channelProxy) {
+    let present = this._presentProxies[channelProxy.name];
+    if (!present) {
+      present = this._presentProxies[channelProxy.name] = new Set();
+    }
+    present.add(channelProxy);
+    return present.size;
+  }
+
+  _removePresent(channelProxy) {
+    let present = this._presentProxies[channelProxy.name];
+    present?.delete(channelProxy);
+    return present?.size || 0;
+  }
+
+  _addSubscribed(channelProxy) {
+    let subscribed = this._subscribedProxies[channelProxy.name];
+    if (!subscribed) {
+      subscribed = this._subscribedProxies[channelProxy.name] = new Set();
+    }
+    subscribed.add(channelProxy);
+    return subscribed.size;
+  }
+
+  _removeSubscribed(channelProxy) {

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

GitHub sha: 31db83527b9b02000f133c782e9e6ab4b4a16659

This commit appears in #13803 which was approved by jjaffeux. It was merged by davidtaylorhq.