Naalya Handbook
Realtime

Using presence

Who counts as online on a channel — participants, observers, and collapsing tabs into users.

Centrifugo tracks clients (one per browser tab). The UI wants users. Presence is that translation, plus a second policy: some people must see the room without appearing in it.

This page is the ticket "show who is on this campus" or "proctor sees the exam roster but students must not see the proctor as a sitting candidate."

Participant vs observer

When the Hub asks for a subscription token it sends mode:

ModeWebsocketPresence list
participantreceives publications, join/leaveincluded
observersame receiptsexcluded — token override presence: { value: false }

The API decides who may use which mode per kind (REALTIME_CHANNELS[kind].modes). Asking for observer on a kind that does not list it is a 403.

Campus staff. A teacher assigned to Lugazi joins campus-staff as a participant — they show as online there. A school manager overviewing all campuses joins every campus-staff channel as an observer (useObserverChannels). They see Lugazi's dots; Lugazi does not see them. Observer is a school-level capability; campus-scoped staff cannot request it.

CBT exam. Students with an in-progress attempt join cbt-exam as participants — that presence is the proctor roster. Proctors join the same channel as observers (invisible) and join cbt-exam-proctor as participants for flags/progress. Student-derived events never land on cbt-exam, so a student cannot receive a peer's flag.

School-staff / admin-staff. Participant only today. No observer mode.


Reading the set in the Hub

useRealtimeChannel already returns presence: Set<string>user ids, not client ids.

const { presence } = useRealtimeChannel(
  RealtimeChannel.CAMPUS_STAFF,
  { campusId },
  { mode: 'participant' },
)

const online = presence.has(staffUserId)

The store uses createPresenceTracker:

  • init from Centrifugo's presence snapshot on subscribe.
  • join / leave increment a per-user client count.
  • A user stays online while at least one of their tabs is subscribed.
  • Closing the last tab removes them.

Do not display client ids. Do not assume one connection per user.

School-admin overview:

const { byCampus } = useObserverChannels({ enabled: isSchoolAdmin })
const lugaziOnline = byCampus.get(lugaziCampusId) // Set<userId> | undefined

Adding presence to a new channel

  1. List 'participant' and, if someone must watch invisibly, 'observer' on the kind — Creating a channel.
  2. Token service: participant = "belongs in the room"; observer = the stricter "may watch." Copy campus-staff or cbt-exam.
  3. Hub: useRealtimeChannel(..., { mode }). For a batch of observer rooms, extend the observer-tokens endpoint rather than N grants from the client.
  4. If the new kind should not have presence semantics (pure event pipe), you can still subscribe; just do not render presence. Empty presence is not a bug.

There is no extra "enable presence" flag on Centrifugo per channel beyond the token override for observers. If presence is on for the namespace, participants appear.


Gotchas

Observer refresh. Mode is not stored server-side. A token refresh that omits mode remints as participant. The user suddenly appears in the list they were watching. Hub getToken must pass mode every time.

Do not authorize off presence. Presence is a hint for the UI. Who may see data is still REST + CASL. An observer on campus-staff does not get student PII from the websocket — that channel currently carries no product events.

Personal has no presence roster for the product. The provider's personal subscription is for user-directed events, not "who is online in the app." Campus/exam channels are the roster.

Tests. realtime-presence.spec.ts locks the client→user collapsing. If you change the tracker, that file is the contract.

Where to go next

On this page