Fills the five highest-value gaps under agents-docs/features/ so the index covers the system's main cross-context contracts. Each doc follows the feature-template structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage or behavior couldn't be confirmed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
14 KiB
Markdown
197 lines
14 KiB
Markdown
# 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 |
|