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>
14 KiB
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_stateenvelope. - Surface current-game activity to peers via the chat data channel.
This area does not own:
- The WebSocket envelope shape — see websocket-envelopes.
- RTC negotiation, peer connection lifecycle, RNNoise — see voice-signaling.
- Server-side account records, credentials, or the
identifyhandshake's authentication semantics — see authentication. - Authorization for joining a server (membership / bans / invites) — see access-control.
Key concepts
oderId— client-asserted identity. The deduplication key across multiple sockets and devices.connectionScope— opaque string (typically the signal URL). Used together withoderIdto coexist multi-URL sockets without an eviction loop.- Status —
'online' | 'away' | 'busy' | 'offline'.VALID_STATUSESlives inserver/src/websocket/handler.ts. Client maps incomingofflineto a localdisconnectedrendering tag. - Manual vs automatic status — manual user choices override the idle-detector.
- Voice presence — derived client-side from
voice_statebroadcasts; the signaling server has no voice-room model. - Game activity — currently-detected game; resolved via
/games/matchand announced to peers over the data channel as agame-activitychat event. ConnectedUser— server-side per-connection row (server/src/websocket/types.ts); see authentication for the full shape.
Envelopes (consumed and emitted)
Schemas live in websocket-envelopes. Presence-relevant types:
identify— first envelope a client sends; updates profile metadata. Re-broadcastsuser_joinedper joined server when profile fields change.join_server/leave_server— connection joins/leaves a specific server scope; gated byauthorizeWebSocketJoin(see access-control).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 sameoderIdalready had this server).user_left— broadcast to peers when an identity fully releases a server; payload includesserverIdslisting 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. CarriesroomId,voiceGateway, mute/deafen flags. Voice membership is reconstructed from these events client-side.keepalive— bumpslastPongon the server to keep the connection from being reaped.access_denied— server response when authorization forjoin_serverfails.
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:
- WebSocket connect — server allocates a
ConnectedUserrow keyed byconnectionId. identify— client claimsoderId, profile metadata, and an optionalconnectionScope. See authentication for the handshake details.join_server— gated byauthorizeWebSocketJoin. On success, the server adds the server touser.serverIds, sends a privateserver_userssnapshot to the joiner, and broadcastsuser_joinedto other peers on that server only if this is a new identity for the server (multi-device dedup).- Steady state —
status_update,voice_state, profile-bearingidentifyupdates, and chat / RTC envelopes flow. The server bumpslastPongon every inbound frame. leave_server/ disconnect — the server removes the server fromuser.serverIds.user_leftis broadcast to peers only once every connection of the sameoderIdhas released the server. The payload'sserverIdsfield reports which servers the identity is still in, so clients can distinguish "moved tabs" from "fully left."- Dead-connection sweep — every
PING_INTERVAL_MS = 30 000the server sweeps; any connection withlastPongolder thanPONG_TIMEOUT_MS = 45 000is 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 / offlinefrom the UI. The chosen value is sent as astatus_updateenvelope and persists locally. - Automatic —
UserStatusService(toju-app/src/app/core/services/user-status.service.ts) listens to ElectronpowerMonitorevents (suspend, resume, lock, unlock) when running on desktop, or falls back to a 15-minute idle timer in the browser. It emitsstatus_updateviaRealtimeSessionFacade.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). - Resolves candidates to RAWG metadata via
POST /games/matchon the signaling server. - Broadcasts the result to peers as a
game-activitychat 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_joinedis emitted only on a new-identity membership;user_leftonly on full release of a server by anoderId.identifyis the canonical channel for profile-metadata updates. Profile-bearingidentifyenvelopes rebroadcastuser_joinedonly if at least one ofdisplayName/description/profileUpdatedAtactually changed.- Voice room membership is never server-tracked. It is client-derived from
voice_statebroadcasts. - Manual status overrides automatic status for the session.
- Reconnection resets all presence state; the joiner's first
server_userssnapshot 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). ConnectedUserrow —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). - 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. powerMonitorevents — wired inelectron/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_leftsuppression. - TODO: no spec for
identify-triggered profile rebroadcast. - TODO: no spec for pong-timeout reaping.
- TODO: no spec for client
offline → disconnectedrendering mapping. - TODO: no E2E for cross-device presence dedup.
Security considerations
- The
identifyclaim is not verified — see authentication. Presence trust is inherited from that gap: a connection can claim anyoderIdand 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 acloseToTraySetting-style toggle. TODO: confirm the current opt-out path. - Status is self-asserted.
busydoes not enforce anything; it is purely a presence hint.
Performance considerations
broadcastToServeris 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
connectedUsersfrom leaking on TCP half-closes. GameActivityServicedefaults to a 10 s poll; user-configurable, clamped 5–60 s.identifyrebroadcast 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_stateevents 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
gameIgnoreListsetting lives inelectron/desktop-settings.tsbut is undocumented in renderer UX terms — TODO.
Related features
- websocket-envelopes — owns the wire shape of every envelope this area uses.
- voice-signaling — consumes the
voice_statebroadcasts to drive RTC mesh. - authentication — owns the
identifyhandshake and the heartbeat / reaping policy. - access-control — gates
join_server, which precedes any presence broadcast for that server. - ipc-bridge — exposes
powerMonitorevents and process-list inspection used by status and game-activity.
Changelog
| Date | Change |
|---|---|
| 2026-05-25 | Initial documentation |