Files
backspace/docs/systems/activity-presence.md
Jannis Braun ba0f8637f5 docs(federation): document username on profile_update + new presence_update relay + stub backfill
- federation.md §10: extend FederationProfileUpdatePayload with username, document
  receiver fallback. Add Presence Sync sub-section: event shape, sender call sites,
  outbox-only (no mutation log) policy, peer-lifecycle hooks, flap recovery
  semantics. Add Stub Username Backfill sub-section + new
  /api/federation/users/by-home-id endpoint.

- activity-presence.md: resolve drift — line 147 previously claimed S2S
  presence_update relays existed but the code didn't ship them. Now points to
  federation.md §10 which describes the actually-implemented mechanism. Connect/
  Disconnect Flow updated to reflect collectProfileBroadcastTargetIds recipient
  set + S2S queueing.

- social.md / websocket.md: presence_update recipient column now reflects
  friends + DM + space co-members (matches user_updated), plus federated stub
  presence sourced via S2S.
2026-05-05 16:19:03 +02:00

19 KiB

Activity & Presence System

Source files:

  • packages/shared/src/types.ts — Activity, ActivityType, ActivityTimestamps, ActivityAssets type definitions
  • packages/shared/src/activities.ts — ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity()
  • packages/web/src/stores/activityStore.ts — Client-side activity state (Zustand), debounced push, visibility toggle
  • packages/web/src/platform/activityBridge.ts — Electron IPC bridge: subscribes to desktop activity events
  • packages/web/src/hooks/useWebSocket.ts — Ready payload handling, presence_update reception, reconnect re-push
  • packages/web/src/components/layout/ActivityPanel.tsx — Friends activity sidebar (DM home view)
  • packages/web/src/components/layout/MemberSidebar.tsx — Space member list with activity display
  • packages/web/src/components/ui/ActivityCard.tsx — Activity display component, accent color helpers
  • packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx — showActivity toggle UI
  • packages/server/src/ws/handler.ts — ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup)
  • packages/server/src/ws/events.ts — handlePresenceUpdate, handleActivityUpdate, validateActivities
  • packages/server/src/utils/presenceBoot.ts — boot-time reset of orphaned users.status rows (federation-safe)
  • packages/server/src/routes/users.ts — REST showActivity toggle with server-side activity clear
  • packages/desktop/src/activityDetector.ts — Process polling, game dictionary matching (boundary: see Desktop section)
  • packages/desktop/src/preload.ts — IPC channel exposure (activity-detected, get-current-activity)
  • packages/desktop/src/main.ts — startActivityDetection call, IPC handler registration

Type Definitions

// packages/shared/src/types.ts

type ActivityType = 'custom' | 'playing' | 'listening' | 'watching' | 'streaming';

interface ActivityTimestamps {
  start?: number;  // epoch ms
  end?: number;    // epoch ms
}

interface ActivityAssets {
  largeImage?: string;
  largeText?: string;
  smallImage?: string;
  smallText?: string;
}

interface Activity {
  type: ActivityType;
  name: string;
  details?: string;
  state?: string;
  timestamps?: ActivityTimestamps;
  assets?: ActivityAssets;
  url?: string;
}

Field Limits & Validation

ACTIVITY_LIMITS (shared/src/activities.ts)

Constant Value
MAX_ACTIVITIES_PER_USER 5
MAX_NAME_LENGTH 128
MAX_DETAILS_LENGTH 128
MAX_STATE_LENGTH 128
MAX_ASSET_TEXT_LENGTH 128
MAX_URL_LENGTH 512

Server-Side Validation (ws/events.ts:validateActivities())

The server validates every incoming activity_update payload:

  1. Must be an array with at most MAX_ACTIVITIES_PER_USER items
  2. Each item must be an object with a valid type (one of: custom, playing, listening, watching, streaming)
  3. name is required, must be a non-empty string within MAX_NAME_LENGTH; trimmed on accept
  4. Optional fields (details, state) accepted if string and within length limits; trimmed
  5. url accepted only if it starts with https:// or http:// and is within MAX_URL_LENGTH
  6. timestamps.start and timestamps.end accepted if numbers in range [0, 4102444800000] (epoch ms cap ~2100)
  7. assets fields (largeImage, smallImage) validated against MAX_URL_LENGTH; text fields against MAX_ASSET_TEXT_LENGTH
  8. If any item fails validation, the entire payload is rejected (returns null)

Activity Priority & Primary Selection

Priority Ranking (shared/src/activities.ts)

Activity Type Priority
streaming 5 (highest)
playing 4
listening 3
watching 2
custom 1 (lowest)

getPrimaryActivity(activities) Algorithm

Returns the single activity with the highest priority from the array. Uses Array.reduce — on ties, the first-encountered activity wins (leftmost in array). Returns null for empty arrays.

// shared/src/activities.ts
function getPrimaryActivity(activities: Activity[]): Activity | null {
  if (!activities.length) return null;
  return activities.reduce((best, current) =>
    ACTIVITY_PRIORITY[current.type] > ACTIVITY_PRIORITY[best.type] ? current : best
  );
}

Presence States

Status Values

Status Meaning
online Active connection
idle User-set idle
dnd Do not disturb
offline No active connections

DB Persistence

The users.status column (see database.md) stores the current presence status. Default: 'offline'.

  • On connect: Server sets status = 'online' in DB at WebSocket auth (ws/handler.ts, the line after authenticated = true). The REST /api/auth/login route does not set status — login alone does not imply a live socket; the WS handshake is the single source of truth.
  • On manual change: Client sends presence_update with status field; server persists to DB (ws/events.ts)
  • On disconnect: After 5s grace period, server sets status = 'offline' in DB (ws/handler.ts:finalizeDisconnect)
  • On boot: Server resets stale rows for locally-homed, non-deleted users (see "Boot Reset" below).

Boot Reset (utils/presenceBoot.ts)

users.status is only flipped back to 'offline' by ConnectionManager.finalizeDisconnect() after a real WS close + 5s grace timer. Those timers live in process memory, so a server restart (deploy, crash, OOM, kill) loses them and any row currently set to 'online', 'idle', or 'dnd' stays frozen at that value forever — making the user appear permanently online to friends and space co-members until they next connect.

resetStalePresenceOnBoot() runs once during server boot in index.ts, after getDb()/seedDatabase() and before WebSocket route registration. It executes a single update:

UPDATE users
   SET status = 'offline'
 WHERE home_instance IS NULL
   AND is_deleted = 0
   AND status != 'offline'

Three guards on the WHERE clause:

  1. home_instance IS NULL — replicated user stubs (federated identities homed elsewhere) have their status projected to us by the home instance via S2S presence_update relay events (see federation.md §10 — Presence Sync). Their status must not be touched on our boot. On peer deactivation, markPeerStubsOffline flips them to offline; on peer (re)activation, the home instance re-emits a fresh snapshot for relationship-related online natives.
  2. is_deleted = 0 — tombstoned users are excluded from presence broadcasts already; their stored status is left alone as a maintenance courtesy (no behavioral effect either way, but avoids silent rewrites).
  3. status != 'offline' — keeps the operation a no-op once steady-state is reached; changes is logged only when non-zero.

Because the in-memory ConnectionManager is empty at boot by construction, no live connection can be misrepresented by this reset.

Connect/Disconnect Flow

  1. Server bootresetStalePresenceOnBoot() flips any locally-homed, non-deleted online/idle/dnd rows to offline. Federated rows untouched.
  2. Auth succeedsstatus set to 'online' in DB → local presence_update broadcast to friends + DM members + space co-members via collectProfileBroadcastTargetIds → S2S presence_update queued to all active peers via queuePresenceRelay (mirrors profile_update fanout).
  3. Last socket closes → 5-second grace period (scheduleDisconnect) to allow tab refresh/reconnect.
  4. Grace period expiresfinalizeDisconnect: sets DB status to 'offline', clears in-memory activities, broadcasts local presence_update to friends/DM/space co-members, queues S2S presence_update to peers.
  5. Reconnect during gracecancelDisconnect prevents offline broadcast; new connection proceeds normally.

Presence Broadcast Scope

presence_update events are broadcast via connectionManager.sendToSpace() to all spaces the user belongs to, plus sendToUser() to the user's own connections (multi-tab sync). The user is excluded from the space broadcast to avoid duplicate delivery.


Activity Lifecycle

Activities are ephemeral — stored only in server memory (ConnectionManager.userActivities: Map<string, Activity[]>), never persisted to the database. They are cleared on disconnect.

Data Flow: Detection to Display

Desktop Process Scanner (15s poll)
  → IPC 'activity-detected' → preload bridge
    → activityBridge.ts → activityStore.pushActivities()
      → 5s debounce → wsSendAll('activity_update')
        → Server validates, rate-limits (3s)
          → Stores in ConnectionManager.userActivities
            → Broadcasts 'presence_update' to all user's spaces
              → Client useWebSocket handler
                → activityStore.setUserActivities()
                  → UI re-renders (ActivityCard, MemberSidebar, ActivityPanel)

Server-Side In-Memory State (ws/handler.ts:ConnectionManager)

Map Key Value Lifecycle
userActivities userId Activity[] Set on activity_update, cleared on disconnect or showActivity=false
userShowActivity userId boolean Cached from DB at auth, updated via REST PATCH /users/me
userStatuses userId string Cached from DB at auth, updated on presence_update
lastActivityUpdate userId timestamp (ms) Used for 3s rate limiting

Rate Limiting

Two independent throttling mechanisms prevent activity spam:

Layer Mechanism Interval Location
Client Debounce timer in activityStore.pushActivities() 5 seconds activityStore.ts:72
Server checkActivityRateLimit() — rejects if < 3000ms since last update 3 seconds ws/handler.ts:349-355

The client debounce is a trailing-edge timer: each new pushActivities() call resets the 5s timer, and only the final state is sent. The server rate limit is a hard gate: updates arriving within 3s of the last accepted update are rejected with an error message.

Ready Payload — Initial Activity Snapshot

On WebSocket auth, buildReadyPayload() constructs a userActivities map for all visible users (space members + DM members). It auto-injects a synthetic custom activity for users who have a customStatus set but no ephemeral activities:

// ws/handler.ts:collectUserActivities
function collectUserActivities(uid: string, customStatus: string | null) {
  if (seenUserIds.has(uid)) return;
  seenUserIds.add(uid);
  let acts = connectionManager.getUserActivities(uid);
  if (acts.length === 0 && customStatus) {
    acts = [{ type: 'custom', name: customStatus }];
  }
  if (acts.length > 0) {
    userActivities[uid] = acts;
  }
}

This synthetic injection only occurs in the ready payload snapshot, not in live presence_update broadcasts.

Reconnect Re-Push

After receiving a ready event, the client performs two re-push operations (useWebSocket.ts:217-236):

  1. Electron re-query: If running in desktop and this is the home connection, calls window.backspace.getCurrentActivity() and pushes the result. This handles sleep/wake scenarios where the process scanner didn't fire a change event.

  2. Multi-instance fan-out: Reads myActivities from the activity store and sends activity_update to the newly connected instance via wsSend(event, origin). This ensures remote instances have the user's current activities in their in-memory store immediately.


Visibility Control (showActivity)

DB Column

users.showActivity — integer, NOT NULL, default 1. See database.md.

Toggle Flow

  1. User toggles in Privacy panel → api.users.update({ showActivity: enabled }) (REST PATCH)
  2. Server persists showActivity to DB (routes/users.ts:324)
  3. Server updates ConnectionManager.userShowActivity cache (routes/users.ts:357)
  4. If toggled off, server immediately:
    • Clears ConnectionManager.userActivities for the user
    • Broadcasts presence_update with activities: [] to all user's spaces
    • Sends same to user's own connections
  5. Client calls activityStore.setShowActivity(enabled) (PrivacyPanel.tsx:73)
  6. If toggled off, client immediately:
    • Cancels any pending debounce timer
    • Sends activity_update with activities: [] to all connected instances via wsSendAll
    • Sets myActivities to null

Server-Side Guard

When showActivity is false, the server silently drops incoming activity_update events (ws/events.ts:505):

function handleActivityUpdate(event, userId) {
  if (!connectionManager.getUserShowActivity(userId)) return;
  // ...
}

Client-Side Guard

activityStore.pushActivities() checks showActivity and returns early if false (activityStore.ts:69).


Desktop Activity Detection (Boundary)

This spec covers how detected activities enter the broadcast pipeline. The detection internals (process scanning, game dictionary matching, dictionary sync) belong to a future desktop.md spec.

Summary of Detection Interface

Component Role
activityDetector.ts:startActivityDetection(callback) Starts 15s polling loop; calls callback with Activity | null on change
activityDetector.ts:getCurrentActivity() Returns current detected Activity or null (synchronous)
main.ts:810-812 Starts detection on app ready; forwards changes via IPC activity-detected
main.ts:814 Registers get-current-activity IPC handler
preload.ts:73-78 Exposes onActivityDetected (subscription) and getCurrentActivity (invoke) to renderer

Bridge to Activity Store

activityBridge.ts is initialized once in AppLayout via useEffect:

  1. Calls initActivityBridge() → subscribes to window.backspace.onActivityDetected
  2. On activity change: calls pushActivities([activity]) or pushActivities([]) (null means no activity)
  3. On init: also queries getCurrentActivity() for immediate state
  4. Cleanup: teardownActivityBridge() removes the IPC listener

Client-Side State: activityStore (Zustand)

State Shape

interface ActivityState {
  userActivities: Map<string, Activity[]>;  // All users' activities, keyed by userId
  showActivity: boolean;                     // Current user's visibility preference
  myActivities: Activity[] | null;           // Current user's own activities (cached locally)
}

Key Methods

Method Behavior
setUserActivities(userId, activities) Updates map; deletes entry if empty array
clearUserActivities(userId) Removes entry from map
initActivities(activityMap) Bulk-set from ready payload (merges into existing map)
setShowActivity(show) Sets flag; if false: cancels debounce, sends empty activity_update via wsSendAll, clears myActivities
pushActivities(activities) Guards on showActivity; sets myActivities immediately; starts/resets 5s debounce timer; on fire: sends activity_update via wsSendAll
reset() Cancels timer, clears all state

Module-Level State

The 5s debounce timer is stored as a module-level let pushTimer variable (not in Zustand state), ensuring it survives React re-renders but is properly cleared on reset() or setShowActivity(false).


Activity Display Components

ActivityCard (ui/ActivityCard.tsx)

Renders the primary activity for a user. Used inside both ActivityPanel and MemberSidebar.

Props: { activities: Activity[], fallbackCustomStatus?: string | null }

Rendering logic:

  1. Get primary activity via getPrimaryActivity(activities)
  2. If no primary and fallbackCustomStatus exists → render custom status as plain text
  3. If primary is custom → render primary.name as plain text
  4. If primary is rich (non-custom) → render primary.name + elapsed time (if timestamps.start set)

Elapsed time format (formatElapsed): "Xh Ym" if hours > 0, otherwise "Xm".

Helper Functions (exported from ActivityCard.tsx)

Function Returns Purpose
getActivityAccentClass(type) Tailwind border class Left-border accent color for glass pill rows
hasRichActivity(activities) boolean True if primary activity is non-custom

Accent Colors by Activity Type

Type Border Class Color
playing border-l-accent-mint Mint
listening border-l-accent-sky Sky
watching border-l-accent-lavender Lavender
streaming border-l-accent-rose Rose
custom (none) No accent

Row Rendering Pattern

Both ActivityPanel and MemberSidebar use the same row rendering logic:

  • Rich activity (non-custom primary): glass-pill container with border-l-2 accent + rounded corners (10px)
  • No rich activity: Standard flat row with hover state

ActivityPanel (layout/ActivityPanel.tsx)

Displayed in the DM home view (right sidebar, 240px wide). Shows friends grouped by activity status.

Friend Categorization

Friends are sorted into three groups using useMemo:

Group Criteria Display
activeFriends Not offline AND primary activity is non-custom Shown first, no header
onlineFriends Not offline AND (no primary OR primary is custom) Header: "ONLINE -- {count}"
offlineFriends Status is offline Header: "OFFLINE -- {count}"

User ID Resolution

Activities are looked up by friend.homeUserId ?? friend.id — this handles federated users whose local ID differs from their home instance ID.

Empty State

When all three groups are empty, displays: "It's quiet for now..." with explanatory text.


MemberSidebar (layout/MemberSidebar.tsx)

Displayed in space views (right sidebar, 240px wide). Shows space members grouped by role, with activity display.

Activity Integration

Activities are looked up by member.userId from the userActivities map. Each member row renders an ActivityCard with fallbackCustomStatus from member.user.customStatus. Offline members do not display activities.

Role Grouping

Members are grouped by highest-positioned role (see getMemberGroup). The owner always sorts first. Activity display is orthogonal to role grouping.


WebSocket Events (Cross-Reference)

See websocket.md for full wire format. Summary of activity-related events:

Client to Server

Event Fields Notes
presence_update status: 'online' | 'idle' | 'dnd' Persisted to DB
activity_update activities: Activity[] Rate-limited 3s server-side; rejected if showActivity=false

Server to Client

Event Fields Scope
presence_update userId, status, activities? All spaces the user belongs to + self

Note: activities field is present only when non-empty. Both presence_update (status change) and activity_update (activity change) result in outbound presence_update events to clients — the server coalesces them into a single event type.

Ready Payload

The ready event includes userActivities: Record<userId, Activity[]> containing activities for all visible users (space members + DM members), with synthetic custom activities injected for users with customStatus but no ephemeral activities.