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

430 lines
19 KiB
Markdown

# 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
```typescript
// 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.
```typescript
// 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 boot**`resetStalePresenceOnBoot()` flips any locally-homed, non-deleted `online`/`idle`/`dnd` rows to `offline`. Federated rows untouched.
2. **Auth succeeds**`status` 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 expires**`finalizeDisconnect`: 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 grace**`cancelDisconnect` 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:
```typescript
// 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`):
```typescript
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
```typescript
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.