# Presence > **Area:** presence > **Status:** Active > **Last updated:** 2026-05-25 ## Overview Presence in Toju covers everything that signals "who is here, where, and doing what" — connection lifecycle (`join_server`, `leave_server`), availability status (`online / away / busy / offline`), profile metadata propagation, voice-room membership, and the current-game indicator. The signaling server **forwards and deduplicates** presence over WebSocket but never persists it: every restart of the server resets all presence state to nothing, and every reconnect of a client re-derives the world from scratch via a `server_users` snapshot. Several signals contribute. Status comes from idle detection in the renderer (Electron `powerMonitor` or browser fallback) and from explicit user choices. Voice membership is **client-derived** from observed `voice_state` broadcasts — the server never tracks who is in a voice room. Game activity is scanned in the renderer using Electron-IPC process inspection, resolved via the server's RAWG-backed `/games/match` API, and broadcast directly to peers over the WebRTC data channel — it never touches the signaling server. ## Responsibilities - Track which users are connected to which server and broadcast joins/leaves with multi-device dedup. - Carry profile metadata (`displayName`, `description`, `profileUpdatedAt`) so peers can render rich identity without a separate lookup. - Propagate availability status (`online / away / busy / offline`) as users choose, idle, or wake. - Surface voice-room membership to peers via the `voice_state` envelope. - Surface current-game activity to peers via the chat data channel. This area does **not** own: - The WebSocket envelope shape — see [websocket-envelopes](./websocket-envelopes.md). - RTC negotiation, peer connection lifecycle, RNNoise — see [voice-signaling](./voice-signaling.md). - Server-side account records, credentials, or the `identify` handshake's authentication semantics — see [authentication](./authentication.md). - Authorization for joining a server (membership / bans / invites) — see [access-control](./access-control.md). ## Key concepts - **`oderId`** — client-asserted identity. The deduplication key across multiple sockets and devices. - **`connectionScope`** — opaque string (typically the signal URL). Used together with `oderId` to coexist multi-URL sockets without an eviction loop. - **Status** — `'online' | 'away' | 'busy' | 'offline'`. `VALID_STATUSES` lives in `server/src/websocket/handler.ts`. Client maps incoming `offline` to a local `disconnected` rendering tag. - **Manual vs automatic status** — manual user choices override the idle-detector. - **Voice presence** — derived client-side from `voice_state` broadcasts; the signaling server has no voice-room model. - **Game activity** — currently-detected game; resolved via `/games/match` and announced to peers over the data channel as a `game-activity` chat event. - **`ConnectedUser`** — server-side per-connection row (`server/src/websocket/types.ts`); see [authentication](./authentication.md) for the full shape. --- ## Envelopes (consumed and emitted) Schemas live in [websocket-envelopes](./websocket-envelopes.md). Presence-relevant types: - `identify` — first envelope a client sends; updates profile metadata. Re-broadcasts `user_joined` per joined server when profile fields change. - `join_server` / `leave_server` — connection joins/leaves a specific server scope; gated by `authorizeWebSocketJoin` (see [access-control](./access-control.md)). - `server_users` — full peer roster sent to a connection when it joins a server; the only "snapshot" envelope. - `user_joined` — broadcast to peers on a server when a new *identity* arrives (i.e. no other connection of the same `oderId` already had this server). - `user_left` — broadcast to peers when an identity fully releases a server; payload includes `serverIds` listing the servers the user is still in elsewhere. - `status_update` — availability status change (`online / away / busy / offline`). - `voice_state` — broadcast to a server when the user enters/leaves voice or toggles mute/deafen. Carries `roomId`, `voiceGateway`, mute/deafen flags. Voice membership is reconstructed from these events client-side. - `keepalive` — bumps `lastPong` on the server to keep the connection from being reaped. - `access_denied` — server response when authorization for `join_server` fails. Game activity events ride the WebRTC **chat data channel** as `game-activity` chat events — not as WebSocket envelopes. --- ## Lifecycle A connection moves through these stages: 1. **WebSocket connect** — server allocates a `ConnectedUser` row keyed by `connectionId`. 2. **`identify`** — client claims `oderId`, profile metadata, and an optional `connectionScope`. See [authentication](./authentication.md) for the handshake details. 3. **`join_server`** — gated by `authorizeWebSocketJoin`. On success, the server adds the server to `user.serverIds`, sends a private `server_users` snapshot to the joiner, and broadcasts `user_joined` to other peers on that server *only if* this is a new identity for the server (multi-device dedup). 4. **Steady state** — `status_update`, `voice_state`, profile-bearing `identify` updates, and chat / RTC envelopes flow. The server bumps `lastPong` on every inbound frame. 5. **`leave_server` / disconnect** — the server removes the server from `user.serverIds`. `user_left` is broadcast to peers *only* once every connection of the same `oderId` has released the server. The payload's `serverIds` field reports which servers the identity is still in, so clients can distinguish "moved tabs" from "fully left." 6. **Dead-connection sweep** — every `PING_INTERVAL_MS = 30 000` the server sweeps; any connection with `lastPong` older than `PONG_TIMEOUT_MS = 45 000` is closed and processed as a disconnect (`server/src/websocket/index.ts`). --- ## Multi-device deduplication `broadcastToServer` (`server/src/websocket/broadcast.ts`) deduplicates fan-out by `oderId`, so a user logged in on two devices sees each peer event once. `handleJoinServer` (`handler.ts:155`) only emits `user_joined` when the join is a **new identity membership** — i.e. no other connection of the same `oderId` already held the server. Renaming a tab or opening a second window does not produce spurious join notifications. Symmetrically, `handleLeaveServer` only emits `user_left` when no other connection of the same `oderId` still holds the server. The `serverIds` field on the payload lets clients see "the identity is still in these other servers" rather than treating a tab close as a full logout. `connectionScope` keeps this stable across multi-signal-URL deployments: an identity that opens connections to two signal URLs is still one identity from the broadcast layer's perspective, but the per-scope stale-eviction does not loop. --- ## Status Two writers update status: - **Manual** — the user picks `online / away / busy / offline` from the UI. The chosen value is sent as a `status_update` envelope and persists locally. - **Automatic** — `UserStatusService` (`toju-app/src/app/core/services/user-status.service.ts`) listens to Electron `powerMonitor` events (suspend, resume, lock, unlock) when running on desktop, or falls back to a 15-minute idle timer in the browser. It emits `status_update` via `RealtimeSessionFacade.sendRawMessage`. Manual overrides automatic for the session — explicit user input prevents the idle detector from overwriting `away` back to `online`. Server-side, `handleStatusUpdate` (`handler.ts:337`) validates the value against `VALID_STATUSES`, mutates `user.status`, and broadcasts the event to every server the user is in. --- ## Game activity `GameActivityService` (`toju-app/src/app/domains/game-activity/application/game-activity.service.ts`) is the renderer-side scanner: - Polls every 5–60 seconds (default 10 s). - Asks Electron for active and running process names via the IPC bridge (`getActiveGameCandidate`, `getRunningProcessNames` — see [ipc-bridge](./ipc-bridge.md)). - Resolves candidates to RAWG metadata via `POST /games/match` on the signaling server. - Broadcasts the result to peers as a `game-activity` chat event on the WebRTC chat data channel. The signaling server is **not** in the broadcast path for game activity — it only matches process names to RAWG entries. Once a peer connection exists, the game-activity envelope flows P2P. --- ## Business rules and invariants - The signaling server **forwards** presence but **never persists** it. Server restart = full reset; clients rederive via `server_users`. - Presence is **per-connection**; identity is reconstructed at broadcast time by collapsing connections that share `oderId`. - `user_joined` is emitted **only** on a new-identity membership; `user_left` **only** on full release of a server by an `oderId`. - `identify` is the canonical channel for profile-metadata updates. Profile-bearing `identify` envelopes rebroadcast `user_joined` only if at least one of `displayName` / `description` / `profileUpdatedAt` actually changed. - **Voice room membership is never server-tracked.** It is client-derived from `voice_state` broadcasts. - Manual status overrides automatic status for the session. - Reconnection resets all presence state; the joiner's first `server_users` snapshot is authoritative for that server. - Dead connections are reaped after 45 s of silence. --- ## Technical implementation ### Server - WebSocket envelope handlers — `server/src/websocket/handler.ts`: `handleStatusUpdate` (line 337), `handleIdentify` (112), `handleJoinServer` (155), `handleLeaveServer` (224). - Broadcast / dedup — `server/src/websocket/broadcast.ts`: `broadcastToServer`, `sendServerUsers`. - Sweep / heartbeat — `server/src/websocket/index.ts` (`PING_INTERVAL_MS`, `PONG_TIMEOUT_MS`). - `ConnectedUser` row — `server/src/websocket/types.ts`. - Game-match HTTP — `server/src/routes/games.ts`, `server/src/services/game-matching.service.ts`. ### Product client - Status writer — `toju-app/src/app/core/services/user-status.service.ts`. - Game-activity scanner — `toju-app/src/app/domains/game-activity/application/game-activity.service.ts`. - Voice presence — `toju-app/src/app/domains/voice-session/` (see [voice-signaling](./voice-signaling.md)). - Presence sync into NgRx — `toju-app/src/app/store/rooms/room-state-sync.effects.ts`, `room-members-sync.effects.ts`. - Presence reducers — `toju-app/src/app/store/users/`. ### Electron - Process inspection IPC — `electron/preload.ts` (`getActiveGameCandidate`, `getRunningProcessNames`). See [ipc-bridge](./ipc-bridge.md). - `powerMonitor` events — wired in `electron/` and surfaced to renderer via IPC events. --- ## Testing - `server/src/websocket/handler-status.spec.ts` — status validation and broadcast. - `toju-app/src/app/store/users/users-status.reducer.spec.ts` — status reducer. - `toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts` — room-level status aggregation. - `toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts` — scanner + RAWG match. - `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — presence-relevant handling. - TODO: no spec for multi-device `user_left` suppression. - TODO: no spec for `identify`-triggered profile rebroadcast. - TODO: no spec for pong-timeout reaping. - TODO: no spec for client `offline → disconnected` rendering mapping. - TODO: no E2E for cross-device presence dedup. --- ## Security considerations - The `identify` claim is **not verified** — see [authentication](./authentication.md). Presence trust is inherited from that gap: a connection can claim any `oderId` and be broadcast as that user. - Game-activity scanning surfaces local process names to the signaling server (via `/games/match`) and to peers (via the data channel). This is privacy-sensitive — there is no per-user opt-out documented today; if a user does not want process names leaving their machine, they need a `closeToTraySetting`-style toggle. TODO: confirm the current opt-out path. - Status is self-asserted. `busy` does not enforce anything; it is purely a presence hint. --- ## Performance considerations - `broadcastToServer` is O(N) per envelope, where N is the number of connections subscribed to the server. There is no fan-out batching. - The 30 s ping cadence keeps idle connections cheap; the 45 s reaper keeps `connectedUsers` from leaking on TCP half-closes. - `GameActivityService` defaults to a 10 s poll; user-configurable, clamped 5–60 s. - `identify` rebroadcast is O(serversJoinedByThisConnection); negligible in practice. --- ## Known issues and limitations - No server-side voice-room membership model means the only way to enumerate a room's occupants is to replay `voice_state` events client-side. - Status idle detection in the browser falls back to a coarse 15-minute timer (no platform idle API). - Game-activity opt-out and granularity (per-game blocklist) are not centrally documented; the `gameIgnoreList` setting lives in `electron/desktop-settings.ts` but is undocumented in renderer UX terms — TODO. --- ## Related features - **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope this area uses. - **[voice-signaling](./voice-signaling.md)** — consumes the `voice_state` broadcasts to drive RTC mesh. - **[authentication](./authentication.md)** — owns the `identify` handshake and the heartbeat / reaping policy. - **[access-control](./access-control.md)** — gates `join_server`, which precedes any presence broadcast for that server. - **[ipc-bridge](./ipc-bridge.md)** — exposes `powerMonitor` events and process-list inspection used by status and game-activity. ## Changelog | Date | Change | |------|--------| | 2026-05-25 | Initial documentation |