42 KiB
Social & Friends System
Source files:
packages/server/src/routes/social.ts-- Friend requests, friend list, unfriend, user discovery, user searchpackages/server/src/routes/users.ts-- User profile CRUD, mutuals endpoint (GET /users/:id/mutuals)packages/web/src/stores/socialStore.ts-- Client-side friend/request state with cross-instance loading and origin taggingpackages/web/src/stores/discoverStore.ts-- Client-side user discovery with multi-instance fan-outpackages/web/src/components/chat/FriendsPage.tsx-- Friends page UI: tabs (Online/All/Pending/Add Friend/Activity), discover grid, searchpackages/web/src/components/modals/UserProfileModal.tsx-- Profile modal with friendship actions and mutual displaypackages/web/src/utils/mutuals.ts-- Cross-instance mutual friend/space loading with deduppackages/web/src/utils/identity.ts-- Federated identity helpers (parseFederatedUsername, isSelf, canonicalUserMatch)packages/web/src/hooks/useWebSocket.ts-- WS event handlers for social events (friend_request_received, etc.)packages/server/src/routes/federation.ts-- Inbound friend relay event processors (5 functions)packages/server/src/utils/federationOutbox.ts--buildFriendContextId(),getFriendEventTargets()packages/server/src/utils/federationWorker.ts-- Initial sync friend backfill for new peers
DB tables: friends, friend_requests, users (discoverable, homeInstance, homeUserId fields).
See docs/systems/database.md for full schemas.
1. Friend Request Lifecycle
State Machine
sender creates
(none) ────────────────────────► pending
│
┌─────────────────┼──────────────────┐
│ │ │
recipient recipient sender
accepts declines cancels
│ │ │
▼ ▼ ▼
accepted declined (row deleted)
│
▼
friends row
inserted
REST Endpoints
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET |
/api/social/friends |
List all friends | JWT |
GET |
/api/social/requests |
List pending friend requests | JWT |
POST |
/api/social/requests |
Send a friend request | JWT |
PATCH |
/api/social/requests/:id |
Accept or decline | JWT |
DELETE |
/api/social/requests/:id |
Cancel outgoing request | JWT |
DELETE |
/api/social/friends/:id |
Remove a friend | JWT |
GET |
/api/social/discover |
Discover users | JWT, rate-limited 30/min |
GET |
/api/social/search |
Search users by name | JWT, rate-limited 30/min |
See docs/systems/api.md for full endpoint signatures.
Send Friend Request (POST /api/social/requests)
Input: { username: string }
Validation chain:
- Username must be non-empty
- Lookup target user by exact username match:
users.username = body.username - Self-friendship prevention:
targetUser.id === request.userIdreturns 400 - Already friends check: Checks
friendstable in both directions (userId/friendId and friendId/userId) - Duplicate request check: Checks
friend_requestsfor any pending request between the two users in either direction
On success:
- Generates snowflake ID, inserts into
friend_requestswithstatus='pending' - WS broadcast:
friend_request_receivedsent to target user with full request payload including sender profile - Federation relay: If either user is federated, queues
friend_request_createevent (see Section 6) - Returns
{ success: true, requestId: string }
Accept/Decline (PATCH /api/social/requests/:id)
Input: { status: 'accepted' | 'declined' }
Authorization: Only the recipient (request.toId === userId) can accept or decline.
Accept path:
- Transaction: Inserts
friendsrow (fromId -> userId, toId -> friendId) AND updates request status to'accepted' - WS broadcast (after commit):
friend_request_acceptedsent to the original sender with the accepting user's profile as aFriendobject - Federation relay: Queues both
friend_request_update(status=accepted) ANDfriend_addevents
Decline path:
- Updates request status to
'declined'(no transaction needed, single write) - WS broadcast:
friend_request_declinedsent to the original sender with{ requestId, userId } - Federation relay: Queues
friend_request_update(status=declined)
Cancel (DELETE /api/social/requests/:id)
Authorization: Only the sender (request.fromId === userId) can cancel.
Validation: Request must be in 'pending' status.
Actions:
- Deletes the request row (not a status update -- full deletion)
- WS broadcast:
friend_request_cancelledsent to the recipient - Federation relay: Queues
friend_request_cancelevent
Remove Friend (DELETE /api/social/friends/:id)
Path parameter: :id is the friend's user ID (not the friendship row ID).
Actions:
- Verifies friendship exists by checking both directions in
friendstable - Deletes the friendship row in both directions (single WHERE with OR)
- WS broadcast:
friend_removedsent to the other user with{ userId: callerUserId } - Federation relay: Queues
friend_removeevent
2. Friend List & Request List
GET /api/social/friends
Queries friends table where the authenticated user is either userId or friendId. Extracts the other user's ID from each row, fetches full user records, and returns as Friend[] with addedAt timestamp from the friendship row's createdAt.
GET /api/social/requests
Queries friend_requests with status='pending' where the authenticated user is either fromId or toId. Enriches each request with the other user's profile (the user who is NOT the requester). Returns as FriendRequest[].
3. User Discovery
GET /api/social/discover
Query params: q (search term), limit (1-100, default 24), offset (default 0)
Filters (WHERE clause):
discoverable = 1-- user must opt into discoveryisDeleted = 0-- exclude tombstoned accountsid != myId-- exclude selfhomeInstance IS NULL OR homeInstance = ''-- exclude replicated federated stubs (each instance only surfaces its own native users; federated users are discovered via the parallel fan-out from the client)- If
qprovided: LIKE match onusernameordisplayNamewith%q%pattern
Pre-loaded social graph (single query each):
- My friend IDs (from
friendstable, both directions) - My space IDs (from
space_members) - Outbound pending requests (Map: toId -> requestId)
- Inbound pending requests (Map: fromId -> requestId)
Batch optimization: For the page of results, fetches ALL friends and space memberships for all page users in two bulk queries (using inArray), then builds per-user Sets for intersection computation.
Per-user computation:
mutualFriendCount: intersection of my friends and their friendsmutualSpaceCount: intersection of my spaces and their spacesrelationship: one of'none'|'friends'|'outbound_pending'|'inbound_pending'requestId: set when relationship isoutbound_pendingorinbound_pending
Sort: mutualFriendCount DESC, then createdAt DESC
Response: { users: DiscoverUser[], total: number }
GET /api/social/search
Query params: q (min 1 character)
Same filter set as discover: isDeleted = 0, discoverable = 1, native-only (homeInstance IS NULL OR ''), excludes self. LIKE match on username or displayName, limit 10. Returns User[] with no mutual counts and no relationship enrichment — the client (FriendsPage.tsx:AddFriendTab) enriches results against the local friends/requests arrays at render time. Federated users are surfaced via the client-side cross-instance fan-out in socialStore.searchUsers, not via this endpoint.
4. Mutuals
GET /api/users/:id/mutuals
Query params: homeUserId (optional, for federation fallback)
Target resolution: Tries path param :id first. If no user found and homeUserId query param is provided, falls back to matching users.homeUserId = homeUserId OR users.id = homeUserId. This handles cases where the caller has a remote user's home ID but not their local replicated stub ID.
Mutual friends: Fetches all friend rows for both the caller and the target (both directions), extracts friend IDs into Sets, computes intersection. Fetches full User records for the mutual friend IDs.
Mutual spaces: Fetches all space_members rows for both the caller and the target, computes intersection of space IDs. Fetches { id, name, icon, avatarColor } for mutual spaces.
Response: { mutualFriends: User[], mutualSpaces: { id, name, icon, avatarColor }[] }
5. WebSocket Events
All social WS events are documented in docs/systems/websocket.md. Summary:
Server -> Client
| Event | Payload | Recipient | When |
|---|---|---|---|
friend_request_received |
{ request: FriendRequest } |
Target user | Request created |
friend_request_accepted |
{ friend: Friend, requestId } |
Original sender | Request accepted |
friend_request_declined |
{ requestId, userId } |
Original sender | Request declined |
friend_request_cancelled |
{ requestId, userId } |
Target user | Sender cancelled |
friend_removed |
{ userId } |
Other user | Unfriended |
user_updated Broadcast (profile changes)
When profile fields change on PATCH /api/users/@me, a user_updated event is broadcast to a deduplicated set of targets:
- All online users who share a space with the updated user
- All co-members of any DM channel the user is in
- All friends of the user (from
friendstable, both directions) - The user themselves (for multi-tab sync)
This ensures friends always see real-time profile updates (avatar, display name, bio, status, etc.).
6. Federation: Friend Relay
Overview
Cross-instance friend operations use 5 relay event types with contextType: 'friend'. The federation relay mechanism (outbox, delivery, HMAC signing) is documented in docs/systems/federation.md. This section covers the application logic specific to friend events.
Event Types
| eventType | Trigger | Authority | Relay Direction |
|---|---|---|---|
friend_request_create |
POST /api/social/requests | Sender's home instance | Sender -> Recipient's instance |
friend_request_update |
PATCH /api/social/requests/:id | Recipient's home instance | Recipient -> Sender's instance |
friend_request_cancel |
DELETE /api/social/requests/:id | Sender's home instance | Sender -> Recipient's instance |
friend_add |
PATCH /api/social/requests/:id (accepted) | Recipient's home instance | Recipient -> Sender's instance |
friend_remove |
DELETE /api/social/friends/:id | Either side's instance | Remover -> Other's instance |
Relay Payload Structure
All friend events use the friendship field of FederationRelayEvent:
interface FederationFriendshipPayload {
from: { homeUserId: string; homeInstance: string }; // Request sender
to: { homeUserId: string; homeInstance: string }; // Request recipient
fromProfile?: FederationRelayProfileSnapshot; // Sender's profile data
toProfile?: FederationRelayProfileSnapshot; // Recipient's profile data
status?: 'pending' | 'accepted' | 'declined'; // Request status (omitted for add/remove/cancel)
createdAt: number; // Epoch ms
}
Profile snapshots (FederationRelayProfileSnapshot) carry { username, displayName, avatar, avatarColor, banner, bio } for hydrating replicated user stubs on the receiving instance.
Identity Resolution for Relay
When building a relay event, each user's identity is resolved as:
const identity = {
homeUserId: user.homeUserId || user.id, // Canonical ID (local users have homeUserId=null)
homeInstance: user.homeInstance || getOurOrigin(), // Full URL for local users
};
Target Peer Selection (federationOutbox.ts:getFriendEventTargets)
function getFriendEventTargets(fromHomeInstance, toHomeInstance): string[] {
const ourOrigin = getOurOrigin();
const targets = new Set<string>();
if (fromHomeInstance && fromHomeInstance !== ourOrigin) targets.add(fromHomeInstance);
if (toHomeInstance && toHomeInstance !== ourOrigin) targets.add(toHomeInstance);
return Array.from(targets);
}
Returns empty array if both users are local (no relay needed). Returns one or two peer origins if one or both users are federated.
Context ID for Friend Events (federationOutbox.ts:buildFriendContextId)
function buildFriendContextId(homeUserIdA: string, homeUserIdB: string): string {
const sorted = [homeUserIdA, homeUserIdB].sort();
return `friend:${sorted[0]}:${sorted[1]}`;
}
Deterministic and direction-independent. Used for outbox coalescing and mutation log grouping.
Entity ID Format
Friend events use two entity ID patterns:
- Requests:
friend_req:{sorted_homeUserIds}:{timestamp}-- e.g.,friend_req:abc:xyz:1711619400000 - Friendships:
friend:{sorted_homeUserIds}:{timestamp}-- e.g.,friend:abc:xyz:1711619400000
The sorted join ensures the same pair always produces the same prefix regardless of direction.
End-to-End Relay Flow: Friend Request Create
Outbound (sender's home instance -- social.ts:POST /api/social/requests):
As of 2026-04-25, the sender's home server owns the entire federated friend-add flow. The client sends { username } verbatim; all parsing, peering, remote lookup, and queueing happen server-side in this strict order:
- Parse target. If
body.usernamecontains no@, or the domain after@normalizes to this server's own host, fall through to the local-only path (unchanged). - resolveOriginFromHostname(targetDomain) — resolves the target peer's full origin URL. Prefers a stored
federation_peersrow matching the typed host; falls back to mirroringgetOurOrigin()'s scheme. Returns null → 400invalid_target_domain. - Authority defense. If the calling user's
homeInstanceis set and does not normalize to this server's own host (checked vianormalizeOriginForCompare), return 403not_authoritative_for_sender. Prevents replicated/federated users from queueing relay events the home server isn't authoritative for. Runs before peering to fail fast. - ensurePeered(peerOrigin) — blocks on the result. Status → HTTP mapping:
'active'→ continue'pending'(handshake in flight) → 409peer_pending'pending'+ peer rowawaiting_approval(re-queried after the call) → 409peer_pending_approval'rejected'→ 403peer_rejected'failed'→ 503peer_unreachable'admin_required'(gate fired locally) → 409peer_pending_local_admin— your own admin must approve before we reach out
- lookupRemoteUser(peerOrigin, baseName) — POSTs HMAC-signed
{ username }topeerOrigin/api/federation/users/lookup. Result mapping:not_found→ 404user_not_foundunreachable→ 503peer_unreachablerate_limited→ 429lookup_rate_limited(withRetry-Afterheader)
- Self-friend pre-check. If the looked-up
(homeUserId, peerOrigin)matches the sender's canonical identity (usingnormalizeOriginForComparefor host comparison) → 400cannot_friend_self. - resolveOrCreateReplicatedUser + hydrateReplicatedUserProfile — creates or refreshes the local stub for the remote user. Tombstoned identities (resolveOrCreateReplicatedUser returns null) → 404
user_not_found. - Direction-aware idempotency:
- Same-direction pending request exists → 200 with existing
requestId(idempotent). - Opposite-direction pending request exists → 409
incoming_request_existswith existingrequestIdfor client deep-link. - Already friends → 409
already_friends.
- Same-direction pending request exists → 200 with existing
- db.transaction(...) (synchronous): inserts the
friend_requestsrow withrelayMessageId = entityId, callsappendMutationLog, callsqueueOutboxEventtargeting[peerOrigin]. - WS broadcast
friend_request_sentto the sender's other tabs/devices (multi-tab sync). - Returns
201 { success: true, requestId }.
The wire format of the queued event is identical to the pre-2026-04-25 flow; only the queueing instance has changed. The receiver's processFriendRequestCreateEvent is unchanged. Both peers' authority checks (from.homeInstance === sourceInstance) continue to pass because the sender's instance is now both source and queueing instance.
Schema note: The
friend_requeststable gained arelayMessageId TEXTcolumn (added 2026-04-25, drizzle migration0001_complex_screwball.sql). It isNULLfor local-only requests; for federated ones it carries theentityIdof the originating relay event so the rollback hook can locate the row by message ID.
Delivery (federation worker -- federationWorker.ts:processOutboxTick):
- Worker polls outbox every 10 seconds
- Groups pending entries by peer, builds batch
FederationRelayRequest - Signs with HMAC, POSTs to
{peerOrigin}/api/federation/relay - On success: deletes outbox entries. On failure: exponential backoff retry.
Inbound (receiving instance -- federation.ts:processFriendRequestCreateEvent):
- Validate:
event.friendshipmust exist,from.homeInstance === sourceInstance(authority check). - Self-target guard (defense-in-depth): if
from.homeUserId === to.homeUserIdandnormalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance), reject withself_target_invalid. Runs before any side effects (no stub creation). The sender's localcannot_friend_selfcheck should catch this, but the receiver must not trust upstream validation. - Resolve sender:
resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance)-- creates stub if needed. - Hydrate sender profile:
hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile)-- updates stub fields. - Resolve recipient:
resolveLocalUser(to.homeUserId)-- must be a native user on this instance (returnsundefinedif not found -> rejectrecipient_not_found). - Idempotency checks:
- Already friends (either direction): accept as no-op.
- Pending request in EITHER direction: accept as no-op. Forward (from→to) covers redelivery; reverse (to→from) covers the cross-fire race where alice@A and bob@B click "add friend" near-simultaneously and each sender's local both-direction check passes before either event reaches the wire. Mirrors the sender-side
incoming_request_existsboth-direction check (step 8 above) to keep the receiver and sender contracts symmetric.
- Create request: Insert
friend_requestsrow with local IDs. - WS broadcast:
friend_request_receivedsent to local recipient with sender's sanitized profile. - Push
event.messageIdto accepted array.
Race outcome. Under the cross-fire scenario both instances converge on a single pending row (whichever event materialized first). The redundant outbound on the other side becomes harmless dead state — the local user already sees the pending request via existing UI. Auto-promotion to mutual friendship when both directions exist is not implemented; it is a product/design conversation, not a correctness fix.
Failure Handling: Async Rollback
When the outbox worker receives a relay response from the remote instance, it classifies each rejected entry. A configurable set of terminal rejection reasons (TERMINAL_REJECTION_REASONS in federationWorker.ts) causes an outbox entry to be deleted with no retry: duplicate, recipient_not_found, attribution_mismatch, unknown_event_type, self_target_invalid.
For non-duplicate terminals, the worker invokes the registered permanent-failure callback via invokePermanentFailureCallback(eventType, messageId, reason) from utils/federationRollback.ts. For friend_request_create, this is rollbackFriendRequestCreate:
- Looks up the
friend_requestsrow byrelayMessageId(the storedentityId). - Deletes the row.
- Emits WS
friend_request_relay_failedto the sender's connections, with a client-facing reason: receiverrecipient_not_found→user_not_found; everything else →peer_rejected.
The client handler in useWebSocket.ts removes the row from socialStore and shows a warning toast.
5xx responses, network errors, and retry exhaustion are NOT terminal — the outbox retries with exponential backoff. The sender sees indefinite "pending" under sustained connectivity loss, matching DM relay's behavior under the same conditions.
Ghost-row risk. Rollback callbacks are best-effort: the registry catches and logs callback errors but does not re-throw. A failed rollback (e.g., DB write fails mid-rollback) leaves a ghost friend_requests row with no corresponding in-flight relay. Acceptable vs. retry-forever blocking the outbox, but worth knowing when debugging stuck pending requests.
End-to-End Relay Flow: Friend Request Update (Accept/Decline)
Outbound (social.ts:PATCH /api/social/requests/:id):
- Local request status updated (+ friendship row if accepted)
- Queues
friend_request_updatewithstatus: 'accepted' | 'declined' - If accepted, also queues
friend_addevent (two separate outbox entries)
Inbound (federation.ts:processFriendRequestUpdateEvent):
- Authority:
to.homeInstance === sourceInstance-- the recipient's instance sends the update - Resolve sender:
resolveLocalUser(from.homeUserId)-- must be local (they sent the original request from this instance) - Resolve recipient:
resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance)-- create stub if needed - Find pending request: Matches
fromId = fromUser.id,toId = toUser.id,status = 'pending' - If no pending request found -> accept idempotently (friend_add may have arrived first)
- Update request status
- WS broadcast:
friend_request_accepted(with Friend payload) orfriend_request_declinedsent to local sender
End-to-End Relay Flow: Friend Request Cancel
Outbound (social.ts:DELETE /api/social/requests/:id):
- Local request deleted
- Queues
friend_request_cancel
Inbound (federation.ts:processFriendRequestCancelEvent):
- Authority:
from.homeInstance === sourceInstance-- the sender cancels their own request - Resolve both users:
resolveLocalUser()for both -- if either doesn't exist, accept idempotently - Find and delete the pending request row
- WS broadcast:
friend_request_cancelledto local recipient
End-to-End Relay Flow: Friend Add
Outbound: Queued alongside friend_request_update (accepted) from social.ts:PATCH.
Inbound (federation.ts:processFriendAddEvent):
- Authority:
to.homeInstance === sourceInstance-- the accepting side creates the friendship - Resolve both users:
resolveOrCreateReplicatedUser()for both, hydrate profiles from snapshots - Idempotency: If friendship row already exists, accept as no-op
- Insert
friendsrow - Auto-resolve pending requests: Updates any pending request between these users to
'accepted'(handles friend_add arriving before friend_request_update due to delivery ordering) - Determine local user: Compare
from.homeInstanceagainstgetOurOrigin()to find who is local - WS broadcast:
friend_request_acceptedsent to local user with remote user's profile (uses empty string forrequestIdsince the original request may not exist locally yet)
End-to-End Relay Flow: Friend Remove
Outbound (social.ts:DELETE /api/social/friends/:id):
- Local friendship deleted
- Queues
friend_remove
Inbound (federation.ts:processFriendRemoveEvent):
- Authority: Either
from.homeInstance === sourceInstanceORto.homeInstance === sourceInstance(either side can unfriend) - Resolve both users:
resolveLocalUser()for both -- if either doesn't exist, accept idempotently - Delete friendship row in both directions
- Determine who was removed: The removing user is on
sourceInstance; broadcastfriend_removedto the other (local) user
7. Initial Sync: Friend Backfill
When a peer transitions to active (including at startup for peers with lastSyncedAt = 0), the federation worker calls onPeerActivated(peerId, reason). One of its two unconditional invariants is syncPeerMutationLog, which pulls missed events from the peer's /api/federation/sync endpoint — including a dedicated friend sync pass.
Flow (federationPeerActivation.ts:syncPeerMutationLog):
- First pass (DM events): Paginates through
POST /federation/syncwith nocontextTypefilter (defaults to DM events), processing each batch viaprocessRelayEvents()directly - Second pass (friend events): Paginates through
POST /federation/syncwithcontextType: 'friend', same direct processing - Third pass (profile events): Paginates through
POST /federation/syncwithcontextType: 'profile', same direct processing - After all three passes complete, updates
lastSyncedAt = Date.now()so the window advances on the next activation
At startup, startupBootstrapSync() scans for status = 'active' AND lastSyncedAt = 0 peers and calls onPeerActivated(peerId, 'startup_bootstrap') for each, preserving the original startup-sync semantics while using the unified path.
The sync endpoint (POST /api/federation/sync) returns events from the federation_mutation_log table, which retains entries for 90 days. This means friend relationships established within the last 90 days are backfilled when a new peer connection is created.
8. Client-Side: socialStore
Source: packages/web/src/stores/socialStore.ts
Origin Tagging
All friends and requests are tagged with _instanceOrigin: string (empty string = home instance, full URL = remote instance). This enables the store to track which API client to use for mutations and to disambiguate users with the same local ID on different instances.
type TaggedFriend = Friend & { _instanceOrigin: string };
type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
type TaggedUser = User & { _instanceOrigin: string };
Cross-Instance Friend Loading (loadFriends)
- Gets connected instances from
instanceStore - Fires
Promise.allSettled()with:- Home instance:
api.social.friends() - Each connected remote instance:
inst.api.social.friends()
- Home instance:
- Deduplication by canonical identity: Uses
Map<string, number>keyed byfriend.homeUserId ?? friend.id. First occurrence wins, but native profiles replace replicated stubs: a native profile (homeInstanceis null) found for a canonical ID that was previously seen as a stub replaces the entry. Critically, the "native" check is!homeInstance, not!homeUserId-- the server backfills native users'homeUserIdto their own id so federation tier-1 lookups succeed (seefederation.ts:backfillHomeUserId), sohomeUserIdis set on natives too. - Asset normalization: For remote-origin friends, calls
normalizeUserAssets(friend, origin)to resolve relative avatar/banner URLs to absolute remote URLs - Stores the merged, tagged array as
friends
Cross-Instance Request Loading (loadRequests)
Same Promise.allSettled() fan-out pattern as loadFriends. Dedup by the other party's canonical identity (request.user.homeUserId ?? request.user.id), preferring the record from the instance where the other party is native (!request.user.homeInstance). This is critical: a cross-instance request exists as two rows -- one on each instance -- and both sides return it, but only the record from the target's home instance has the canonical (non-stub) user ids and the correct _instanceOrigin tag. Matching those is what lets the Add Friend search card flip to "Request Pending" after sending. Normalizes assets for remote request user profiles.
Sending Friend Requests
sendFriendRequest(username: string) sends the trimmed handle verbatim to the home instance API (POST /api/social/requests). As of 2026-04-25, all routing, peering, and remote lookup happen server-side — the client no longer resolves the domain to a connected instance or throws InstanceNotConnectedError/InstanceDisconnectedError. The server returns a structured error code on any failure; the catch block in socialStore maps it via mapServerErrorToMessage from packages/web/src/utils/friendErrors.ts and surfaces it as a toast.
After success, reloads requests via loadRequests().
Cross-Instance Search (searchUsers)
- Fires parallel searches to home + all connected instances
- Deduplication by canonical identity: Uses
Map<string, number>keyed byuser.homeUserId ?? user.id- First occurrence wins, but native profiles replace replicated stubs: if a native profile (
homeInstanceis null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry - The "native" check is
!homeInstance, not!homeUserId. Native users havehomeUserIdbackfilled to their own id by the server so federation tier-1 lookups succeed (federation.ts:backfillHomeUserId).homeInstanceis the only field that reliably distinguishes native users (null) from replicated stubs (set to domain). - This ensures the user sees the "real" profile (including the correct
_instanceOrigintag) rather than a replicated stub whose origin would be the caller's home instance
- First occurrence wins, but native profiles replace replicated stubs: if a native profile (
Instance API Resolution (getApiForOrigin)
function getApiForOrigin(origin: string) {
if (!origin) return api; // Home instance
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
return instance?.api ?? api; // Fallback to home if not found
}
Used by updateFriendRequest, cancelFriendRequest, and removeFriend to route mutations to the correct instance.
WS Event Handlers
From useWebSocket.ts, social events are dispatched to store methods:
| WS Event | Store Method | Effect |
|---|---|---|
friend_request_received |
addIncomingRequest(request, origin) |
Appends to requests (dedup check by id:origin) |
friend_request_accepted |
addFriendFromAccepted(friend, requestId, origin) |
Appends to friends, removes matching request |
friend_removed |
removeFriendLocally(userId, origin) |
Filters friend out by id + origin |
friend_request_cancelled |
removeRequestById(requestId, origin) |
Filters request out by id + origin |
friend_request_declined |
removeRequestById(requestId, origin) |
Filters request out by id + origin |
All handlers also update discoverStore relationship state via lazy import.
Live Updates
| WS Event | Store Method | Effect |
|---|---|---|
presence_update |
updateFriendPresence(userId, status) |
Updates status on matching friend by ID (all origins). Server broadcasts to friends + DM co-members + space co-members (collectProfileBroadcastTargetIds). For federated friends, status is projected by the home instance via S2S presence_update relay (see federation.md §10 — Presence Sync) and broadcast to the same recipient set on the receiving instance. |
user_updated |
updateFriendProfile(user) |
Updates displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status on matching friend by ID |
9. Client-Side: discoverStore
Source: packages/web/src/stores/discoverStore.ts
Federation-Aware Initialization Guard
fetchUsers() includes a critical guard that waits for instanceStore._autoConnectDone before proceeding:
if (!useInstanceStore.getState()._autoConnectDone) {
await new Promise<void>((resolve) => {
const unsub = useInstanceStore.subscribe((state) => {
if (state._autoConnectDone) { unsub(); resolve(); }
});
// Double-check (race condition guard)
if (useInstanceStore.getState()._autoConnectDone) { unsub(); resolve(); }
});
}
This ensures the discover page doesn't fire requests before all remote instance connections are established, which would miss remote users.
Multi-Instance Fan-Out
- Fires
Promise.allSettled()to home + all connected instances'api.social.discover(query) - Tags each user with
_instanceOrigin - Deduplication: By
${user.id}:${origin}-- since the server already excludes replicated stubs from discover results, cross-instance dedup is minimal (only needed for edge cases) - Sums
totalfrom all instances - Error handling: If no instances respond, sets error
'Failed to reach any instance for discovery'
State Shape
interface DiscoverState {
users: TaggedDiscoverUser[]; // Origin-tagged discover users
searchQuery: string; // Current search term
isLoading: boolean;
total: number; // Sum across all instances
error: string | null;
}
Relationship Updates
updateRelationship(userId, origin, relationship, requestId?) -- Updates a specific user's relationship status in-place. Called from:
UserDiscoverCardafter sending/cancelling/accepting friend requests- WS event handlers (friend_request_accepted, friend_removed, friend_request_cancelled, friend_request_declined)
10. Client-Side: Mutuals
Source: packages/web/src/utils/mutuals.ts
loadFederatedMutuals(targetUserId, targetHomeUserId?)
Follows the same Promise.allSettled() fan-out pattern:
- Computes
canonicalHomeId = targetHomeUserId ?? targetUserId - Fires
api.users.getMutuals(targetUserId, canonicalHomeId)to home + all connected instances - Friend dedup: By canonical identity
friend.homeUserId ?? friend.id(prevents the same friend appearing from multiple instances) - Space dedup: By
${space.id}:${origin}(spaces on different instances are distinct entities) - Asset normalization: Remote-origin friend avatars and space icons are resolved to absolute URLs
Types
type TaggedMutualFriend = User & { _instanceOrigin: string };
interface MutualSpace {
id: string;
name: string;
icon: string | null;
avatarColor: string | null;
_instanceOrigin: string;
}
11. Client-Side: Identity Utilities
Source: packages/web/src/utils/identity.ts
parseFederatedUsername(username)
Splits "erin@nova.ddns.net" into { baseName: "erin", domain: "nova.ddns.net" }. Uses indexOf('@') (first occurrence). Returns { baseName: username, domain: null } for non-federated usernames.
isSelf(user, homeUser)
Determines if a user object represents the current user (including cross-instance replicas):
- Same
id-> true user.idin_knownSelfIdsset (populated from WSreadyevents) -> trueuser.homeInstance === window.location.hostAND base username matches -> true
canonicalUserMatch(a, b)
Federation-safe identity comparison with cascading strategies:
- Same
id-> true homeUserIdcross-matching:a.homeUserId === b.homeUserId, ora.homeUserId === b.id, orb.homeUserId === a.id-> true- Username + homeInstance fallback: Parse base names, compare home instances (accounting for null = local)
Used by UserProfileModal:getFriendshipStatus() to find the correct friend/request for a viewed user across instances.
12. FriendsPage UI
Source: packages/web/src/components/chat/FriendsPage.tsx
Tabs
| Tab | Content | Key Behavior |
|---|---|---|
| Online | Online friends only | Filters by status !== 'offline' |
| All | Complete friend list | No filter |
| Pending | Incoming + outgoing requests | Split into sections; incoming shows badge count in tab |
| Add Friend | Search + discover grid | Unified search/discover with direct-add |
| Activity | Friends grouped by activity | Active (rich presence) / Online (no activity) / Offline sections |
Add Friend Tab: Dual-Mode Search
The Add Friend tab merges search and discovery into a single UI:
- Empty query: Shows discover grid (from
discoverStore.fetchUsers(), loaded on mount) - Query entered: Switches to search mode (debounced 300ms, uses
socialStore.searchUsers()) - Direct-Add row: Shown whenever the search input is non-empty and resolves to a well-formed handle (
trimmed.length > 0 && (no @ || @ at non-edge position)). Displays the resolved form: when the typed query has no@, the row shows<query>@<window.location.host>so the user sees which instance the request will hit; when@is present, displays the typed query verbatim. The submit button callssendFriendRequest(query.trim())— the resolved form is display-only. All routing, peering, and remote lookup happen server-side onPOST /api/social/requests(see §6 outbound flow). Server-sidePOST /api/social/requestslowercases the lookup input before matching, so mixed-case bare handles resolve too.
Error handling: Server errors surface as toasts via mapServerErrorToMessage in packages/web/src/utils/friendErrors.ts. All structured error codes returned by the federated branch (user_not_found, peer_pending, peer_rejected, incoming_request_exists, etc.) are mapped to human-readable messages there. The ConnectInstanceModal component still exists in the codebase but is no longer triggered by friend-add — it is used only by the Connections settings panel and space-join flows.
Search Result Enrichment
Raw search results (User[]) are enriched at render time into TaggedDiscoverUser[] by checking against the current friends and requests arrays in socialStore:
- If friend ->
relationship: 'friends' - If outbound pending request ->
relationship: 'outbound_pending'withrequestId - If inbound pending request ->
relationship: 'inbound_pending'withrequestId - Otherwise ->
relationship: 'none'
Self-exclusion uses a precomputed Set<string> of ${id}:${origin} for the current user across all connected instances.
UserDiscoverCard
Renders a card with banner, avatar, display name, username, bio, mutual counts, instance badge (for remote users), and a context-sensitive action button:
none: "Send Friend Request"outbound_pending: "Request Pending" (click to cancel)inbound_pending: "Accept" / "Decline" buttonsfriends: "Message" button
When sending a request to a remote user, constructs baseName@originHost format for the username. Errors from the server are surfaced as toasts via mapServerErrorToMessage (see packages/web/src/utils/friendErrors.ts).
13. UserProfileModal
Source: packages/web/src/components/modals/UserProfileModal.tsx
Friendship Status Resolution
Uses getFriendshipStatus() with canonicalUserMatch() for federation-safe matching:
function getFriendshipStatus(viewedUser, currentUser, friends, requests): FriendshipStatus
→ { state: 'self' } // isSelf() check
| { state: 'friends', friend } // canonicalUserMatch against friends list
| { state: 'outbound_pending', request } // request.user matches viewed user, user.id === toId
| { state: 'inbound_pending', request } // request.user matches viewed user, user.id === fromId
| { state: 'none' }
Tabs
| Tab | Content |
|---|---|
| About | Bio (rendered as Markdown: p, strong, em, a, br), Member Since date |
| Mutual Friends | Grid of mutual friends (from loadFederatedMutuals), clickable to navigate to their profile |
| Mutual Spaces | List of mutual spaces with icons, clickable to navigate to space |
Action Buttons
Displayed in footer based on friendship state:
- Always: "Send Message" (opens/creates DM)
none: "Add Friend"outbound_pending: "Cancel Request"inbound_pending: "Accept" + "Ignore" (decline)friends: "Remove Friend"
All actions route through socialStore methods, which handle instance routing via origin tags.
Federation Support
- User profile is loaded via
getApiForOrigin(origin)to fetch from the correct instance - Banner/avatar URLs resolved through the correct API client for remote users
- Mutuals loaded via
loadFederatedMutuals()with cross-instance fan-out - Friend actions use
sendFriendRequest(user.username)— all routing is server-side; errors surface as toasts viamapServerErrorToMessage
14. Data Types
Friend (shared)
interface Friend {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
banner: string | null;
accentColor: string | null;
avatarColor: AvatarColor | null;
bio: string | null;
status: UserStatus;
customStatus: string | null;
createdAt: number;
addedAt: number; // From friends.createdAt
homeUserId: string | null;
homeInstance: string | null;
}
FriendRequest (shared)
interface FriendRequest {
id: string;
fromId: string;
toId: string;
status: 'pending' | 'accepted' | 'declined';
createdAt: number;
user?: User; // The OTHER party (sender for incoming, recipient for outgoing)
}
DiscoverUser (shared)
interface DiscoverUser {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
banner: string | null;
avatarColor: AvatarColor | null;
bio: string | null;
status: UserStatus;
customStatus: string | null;
createdAt: number;
homeInstance: string | null;
homeUserId: string | null;
mutualFriendCount: number;
mutualSpaceCount: number;
relationship: 'none' | 'friends' | 'outbound_pending' | 'inbound_pending';
requestId?: string;
}