- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
17 KiB
Activity & Presence System
Source files:
packages/shared/src/types.ts— Activity, ActivityType, ActivityTimestamps, ActivityAssets type definitionspackages/shared/src/activities.ts— ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity()packages/web/src/stores/activityStore.ts— Client-side activity state (Zustand), debounced push, visibility togglepackages/web/src/platform/activityBridge.ts— Electron IPC bridge: subscribes to desktop activity eventspackages/web/src/hooks/useWebSocket.ts— Ready payload handling, presence_update reception, reconnect re-pushpackages/web/src/components/layout/ActivityPanel.tsx— Friends activity sidebar (DM home view)packages/web/src/components/layout/MemberSidebar.tsx— Space member list with activity displaypackages/web/src/components/ui/ActivityCard.tsx— Activity display component, accent color helperspackages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx— showActivity toggle UIpackages/server/src/ws/handler.ts— ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup)packages/server/src/ws/events.ts— handlePresenceUpdate, handleActivityUpdate, validateActivitiespackages/server/src/routes/users.ts— REST showActivity toggle with server-side activity clearpackages/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:
- Must be an array with at most
MAX_ACTIVITIES_PER_USERitems - Each item must be an object with a valid
type(one of:custom,playing,listening,watching,streaming) nameis required, must be a non-empty string withinMAX_NAME_LENGTH; trimmed on accept- Optional fields (
details,state) accepted if string and within length limits; trimmed urlaccepted only if it starts withhttps://orhttp://and is withinMAX_URL_LENGTHtimestamps.startandtimestamps.endaccepted if numbers in range[0, 4102444800000](epoch ms cap ~2100)assetsfields (largeImage,smallImage) validated againstMAX_URL_LENGTH; text fields againstMAX_ASSET_TEXT_LENGTH- 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 (ws/handler.ts:1344) - On manual change: Client sends
presence_updatewithstatusfield; server persists to DB (ws/events.ts:483) - On disconnect: After 5s grace period, server sets
status = 'offline'in DB (ws/handler.ts:225)
Connect/Disconnect Flow
- Auth succeeds →
statusset to'online'in DB →presence_updatebroadcast to all user's spaces (excludes self; self getsreadypayload) - Last socket closes → 5-second grace period (
scheduleDisconnect) to allow tab refresh/reconnect - Grace period expires →
finalizeDisconnect: sets DB status to'offline', clears in-memory activities, broadcastspresence_updatewithstatus: 'offline'andactivities: []to all spaces - Reconnect during grace →
cancelDisconnectprevents 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):
-
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. -
Multi-instance fan-out: Reads
myActivitiesfrom the activity store and sendsactivity_updateto the newly connected instance viawsSend(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
- User toggles in Privacy panel →
api.users.update({ showActivity: enabled })(REST PATCH) - Server persists
showActivityto DB (routes/users.ts:324) - Server updates
ConnectionManager.userShowActivitycache (routes/users.ts:357) - If toggled off, server immediately:
- Clears
ConnectionManager.userActivitiesfor the user - Broadcasts
presence_updatewithactivities: []to all user's spaces - Sends same to user's own connections
- Clears
- Client calls
activityStore.setShowActivity(enabled)(PrivacyPanel.tsx:73) - If toggled off, client immediately:
- Cancels any pending debounce timer
- Sends
activity_updatewithactivities: []to all connected instances viawsSendAll - Sets
myActivitiestonull
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:
- Calls
initActivityBridge()→ subscribes towindow.backspace.onActivityDetected - On activity change: calls
pushActivities([activity])orpushActivities([])(null means no activity) - On init: also queries
getCurrentActivity()for immediate state - 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:
- Get primary activity via
getPrimaryActivity(activities) - If no primary and
fallbackCustomStatusexists → render custom status as plain text - If primary is
custom→ renderprimary.nameas plain text - If primary is rich (non-custom) → render
primary.name+ elapsed time (iftimestamps.startset)
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-pillcontainer withborder-l-2accent + 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.