Presence
Who's online in a group - opt-in per group, identity projected to id-only by default, deduped by user, and governed by the same GroupPolicy that gates the room.
Presence
Show who's online in a group ("3 people in this room") with no polling. Presence rides the same SSE/WS connections you already have: when a user connects to or disconnects from a presence-enabled group, the framework broadcasts a small presence:* event to that group.
Because presence exposes user identity, it is locked down by default. It is off for every group until you opt a group in, only signed-in users appear, and a present user is broadcast as { "id": "<user_id>" } and nothing else (the id is the PK string) unless you supply a resolver.
use umbral_realtime::{PresenceSpec, RealtimePlugin}; RealtimePlugin::new() // Enable presence ONLY for groups named `room:*`. Every other group emits nothing. .with_presence(PresenceSpec::prefixes(["room:"]))You can match groups with a predicate instead of a prefix set:
PresenceSpec::matching(|group| group == "public:lobby" || group.starts_with("room:"))The client: umbral.realtime.presence
Sugar over subscribe that routes a group's presence:sync / presence:join / presence:leave events to handlers. It returns the same { unsubscribe } and shares the one cross-tab connection.
<script src="/realtime/client.js"></script><script> umbral.realtime.presence('room:42', { sync: function (members) { /* [{id, ...}] - the current member list, sent on join */ }, join: function (member) { /* one user became present */ }, leave: function (member) { /* one user fully left */ }, });</script>sync arrives once when you join (the snapshot of who's already there); join / leave stream the transitions after that.
Identity projection
The default payload is id-only - never the raw user row. The id is the user's PK rendered as a string, so it works for any PK type (i64, String, uuid):
{ "id": "7" }That's deliberate: the client knows who is present by id and looks the rest up through its own authorized path. If you want to broadcast more (a display name, an avatar), supply a resolver - Fn(&str) -> serde_json::Value, where the argument is the user's PK string. This is your explicit choice of what's safe to put on the wire:
PresenceSpec::prefixes(["room:"]).resolver(|uid| serde_json::json!({ "id": uid, // the user's PK string "name": display_name(uid), // whatever you decide is safe to broadcast}))The resolver's output is the member payload for sync / join / leave - exactly those keys, nothing else.
Dedup by user
Presence is per user, not per connection. A user with three tabs open in a room is "present" once:
presence:joinfires only on the user's first connection into a group.presence:leavefires only when their last connection leaves it.
Opening a second tab adds no second join; closing one of two tabs is not a leave. Closing the last one is.
Security
Off unless enabled
Presence is off for every group until
with_presencematches it. A group the spec doesn't match emits no
presence:*events at all.
Authenticated only, id-only
Anonymous connections (no signed-in user) never appear in presence - nothing to dedupe on, and "someone anonymous is here" is itself a leak. A present user is
{"id": …}only, unless your resolver returns more.
GroupPolicy gates visibility
GroupPolicy::can_join governs who can *see* it: a client that can't join room:42 never receives its presence. Enabling presence never widens who may subscribe.Presence is off unless you enable it, and it only exposes the id (or exactly what your resolver returns). It never broadcasts the raw user row, and anonymous connections never appear.
See also
- Gating access - the
GroupPolicythat governs who can see a group's presence. - Transports - the SSE/WS connections presence rides on, and
subscribe. - Model subscriptions - the sibling opt-in feature with the same id-only-by-default safety stance.