Files

42 KiB

Social & Friends System

Source files:

  • packages/server/src/routes/social.ts -- Friend requests, friend list, unfriend, user discovery, user search
  • packages/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 tagging
  • packages/web/src/stores/discoverStore.ts -- Client-side user discovery with multi-instance fan-out
  • packages/web/src/components/chat/FriendsPage.tsx -- Friends page UI: tabs (Online/All/Pending/Add Friend/Activity), discover grid, search
  • packages/web/src/components/modals/UserProfileModal.tsx -- Profile modal with friendship actions and mutual display
  • packages/web/src/utils/mutuals.ts -- Cross-instance mutual friend/space loading with dedup
  • packages/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:

  1. Username must be non-empty
  2. Lookup target user by exact username match: users.username = body.username
  3. Self-friendship prevention: targetUser.id === request.userId returns 400
  4. Already friends check: Checks friends table in both directions (userId/friendId and friendId/userId)
  5. Duplicate request check: Checks friend_requests for any pending request between the two users in either direction

On success:

  1. Generates snowflake ID, inserts into friend_requests with status='pending'
  2. WS broadcast: friend_request_received sent to target user with full request payload including sender profile
  3. Federation relay: If either user is federated, queues friend_request_create event (see Section 6)
  4. 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:

  1. Transaction: Inserts friends row (fromId -> userId, toId -> friendId) AND updates request status to 'accepted'
  2. WS broadcast (after commit): friend_request_accepted sent to the original sender with the accepting user's profile as a Friend object
  3. Federation relay: Queues both friend_request_update (status=accepted) AND friend_add events

Decline path:

  1. Updates request status to 'declined' (no transaction needed, single write)
  2. WS broadcast: friend_request_declined sent to the original sender with { requestId, userId }
  3. 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:

  1. Deletes the request row (not a status update -- full deletion)
  2. WS broadcast: friend_request_cancelled sent to the recipient
  3. Federation relay: Queues friend_request_cancel event

Remove Friend (DELETE /api/social/friends/:id)

Path parameter: :id is the friend's user ID (not the friendship row ID).

Actions:

  1. Verifies friendship exists by checking both directions in friends table
  2. Deletes the friendship row in both directions (single WHERE with OR)
  3. WS broadcast: friend_removed sent to the other user with { userId: callerUserId }
  4. Federation relay: Queues friend_remove event

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):

  1. discoverable = 1 -- user must opt into discovery
  2. isDeleted = 0 -- exclude tombstoned accounts
  3. id != myId -- exclude self
  4. homeInstance 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)
  5. If q provided: LIKE match on username or displayName with %q% pattern

Pre-loaded social graph (single query each):

  • My friend IDs (from friends table, 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 friends
  • mutualSpaceCount: intersection of my spaces and their spaces
  • relationship: one of 'none' | 'friends' | 'outbound_pending' | 'inbound_pending'
  • requestId: set when relationship is outbound_pending or inbound_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:

  1. All online users who share a space with the updated user
  2. All co-members of any DM channel the user is in
  3. All friends of the user (from friends table, both directions)
  4. 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:

  1. Parse target. If body.username contains no @, or the domain after @ normalizes to this server's own host, fall through to the local-only path (unchanged).
  2. resolveOriginFromHostname(targetDomain) — resolves the target peer's full origin URL. Prefers a stored federation_peers row matching the typed host; falls back to mirroring getOurOrigin()'s scheme. Returns null → 400 invalid_target_domain. 2a. Limbo-window guard → 409 peer_reset_pending. O(1) point lookup on the federation_reset_events origin PRIMARY KEY: if an unresolved row exists for peerOrigin (origin = peerOrigin AND resolved_at IS NULL), the peer was reset-detected (wipe-and-reinstall) but the admin has not yet re-peered — the local friendship/stub graph is still bound to the dead incarnation. Return 409 peer_reset_pending instead of the confusing already_friends (stale friendship) or peer_rejected (the needs_attention peer would otherwise trip ensurePeered). peerOrigin is the exact string markPeerReset journals (the peer's federation_peers.origin), so the match is a single indexed lookup; no reset in progress → one indexed miss → the normal path proceeds unchanged. See docs/systems/federation.md (instance-epoch self-healing) and the design spec §5.3. The equivalent guard runs on federated DM-create (POST /api/dm, dm.ts).
  3. Authority defense. If the calling user's homeInstance is set and does not normalize to this server's own host (checked via normalizeOriginForCompare), return 403 not_authoritative_for_sender. Prevents replicated/federated users from queueing relay events the home server isn't authoritative for. Runs before peering to fail fast.
  4. ensurePeered(peerOrigin) — blocks on the result. Status → HTTP mapping:
    • 'active' → continue
    • 'pending' (handshake in flight) → 409 peer_pending
    • 'pending' + peer row awaiting_approval (re-queried after the call) → 409 peer_pending_approval
    • 'rejected' → 403 peer_rejected
    • 'failed' → 503 peer_unreachable
    • 'admin_required' (gate fired locally) → 409 peer_pending_local_admin — your own admin must approve before we reach out
  5. lookupRemoteUser(peerOrigin, baseName) — POSTs HMAC-signed { username } to peerOrigin/api/federation/users/lookup. Result mapping:
    • not_found → 404 user_not_found
    • unreachable → 503 peer_unreachable
    • rate_limited → 429 lookup_rate_limited (with Retry-After header)
  6. Self-friend pre-check. If the looked-up (homeUserId, peerOrigin) matches the sender's canonical identity (using normalizeOriginForCompare for host comparison) → 400 cannot_friend_self.
  7. resolveOrCreateReplicatedUser + hydrateReplicatedUserProfile — creates or refreshes the local stub for the remote user. Tombstoned identities (resolveOrCreateReplicatedUser returns null) → 404 user_not_found.
  8. Direction-aware idempotency:
    • Same-direction pending request exists → 200 with existing requestId (idempotent).
    • Opposite-direction pending request exists → 409 incoming_request_exists with existing requestId for client deep-link.
    • Already friends → 409 already_friends.
  9. db.transaction(...) (synchronous): inserts the friend_requests row with relayMessageId = entityId, calls appendMutationLog, calls queueOutboxEvent targeting [peerOrigin].
  10. WS broadcast friend_request_sent to the sender's other tabs/devices (multi-tab sync).
  11. 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_requests table gained a relayMessageId TEXT column (added 2026-04-25, drizzle migration 0001_complex_screwball.sql). It is NULL for local-only requests; for federated ones it carries the entityId of the originating relay event so the rollback hook can locate the row by message ID.

Delivery (federation worker -- federationWorker.ts:processOutboxTick):

  1. Worker polls outbox every 10 seconds
  2. Groups pending entries by peer, builds batch FederationRelayRequest
  3. Signs with HMAC, POSTs to {peerOrigin}/api/federation/relay
  4. On success: deletes outbox entries. On failure: exponential backoff retry.

Inbound (receiving instance -- federation.ts:processFriendRequestCreateEvent):

  1. Validate: event.friendship must exist, from.homeInstance === sourceInstance (authority check).
  2. Self-target guard (defense-in-depth): if from.homeUserId === to.homeUserId and normalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance), reject with self_target_invalid. Runs before any side effects (no stub creation). The sender's local cannot_friend_self check should catch this, but the receiver must not trust upstream validation.
  3. Resolve sender: resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance) -- creates stub if needed.
  4. Hydrate sender profile: hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile) -- updates stub fields.
  5. Resolve recipient: resolveLocalUser(to.homeUserId) -- must be a native user on this instance (returns undefined if not found -> reject recipient_not_found).
  6. 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_exists both-direction check (step 8 above) to keep the receiver and sender contracts symmetric.
  7. Create request: Insert friend_requests row with local IDs.
  8. WS broadcast: friend_request_received sent to local recipient with sender's sanitized profile.
  9. Push event.messageId to 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:

  1. Looks up the friend_requests row by relayMessageId (the stored entityId).
  2. Deletes the row.
  3. Emits WS friend_request_relay_failed to the sender's connections, with a client-facing reason: receiver recipient_not_founduser_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):

  1. Local request status updated (+ friendship row if accepted)
  2. Queues friend_request_update with status: 'accepted' | 'declined'
  3. If accepted, also queues friend_add event (two separate outbox entries)

Inbound (federation.ts:processFriendRequestUpdateEvent):

  1. Authority: to.homeInstance === sourceInstance -- the recipient's instance sends the update
  2. Resolve sender: resolveLocalUser(from.homeUserId) -- must be local (they sent the original request from this instance)
  3. Resolve recipient: resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance) -- create stub if needed
  4. Find pending request: Matches fromId = fromUser.id, toId = toUser.id, status = 'pending'
  5. If no pending request found -> accept idempotently (friend_add may have arrived first)
  6. Update request status
  7. WS broadcast: friend_request_accepted (with Friend payload) or friend_request_declined sent to local sender

End-to-End Relay Flow: Friend Request Cancel

Outbound (social.ts:DELETE /api/social/requests/:id):

  1. Local request deleted
  2. Queues friend_request_cancel

Inbound (federation.ts:processFriendRequestCancelEvent):

  1. Authority: from.homeInstance === sourceInstance -- the sender cancels their own request
  2. Resolve both users: resolveLocalUser() for both -- if either doesn't exist, accept idempotently
  3. Find and delete the pending request row
  4. WS broadcast: friend_request_cancelled to local recipient

End-to-End Relay Flow: Friend Add

Outbound: Queued alongside friend_request_update (accepted) from social.ts:PATCH.

Inbound (federation.ts:processFriendAddEvent):

  1. Authority: to.homeInstance === sourceInstance -- the accepting side creates the friendship
  2. Resolve both users: resolveOrCreateReplicatedUser() for both, hydrate profiles from snapshots
  3. Idempotency: If friendship row already exists, accept as no-op
  4. Insert friends row
  5. 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)
  6. Determine local user: Compare from.homeInstance against getOurOrigin() to find who is local
  7. WS broadcast: friend_request_accepted sent to local user with remote user's profile (uses empty string for requestId since the original request may not exist locally yet)

End-to-End Relay Flow: Friend Remove

Outbound (social.ts:DELETE /api/social/friends/:id):

  1. Local friendship deleted
  2. Queues friend_remove

Inbound (federation.ts:processFriendRemoveEvent):

  1. Authority: Either from.homeInstance === sourceInstance OR to.homeInstance === sourceInstance (either side can unfriend)
  2. Resolve both users: resolveLocalUser() for both -- if either doesn't exist, accept idempotently
  3. Delete friendship row in both directions
  4. Determine who was removed: The removing user is on sourceInstance; broadcast friend_removed to 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):

  1. First pass (DM events): Paginates through POST /federation/sync with no contextType filter (defaults to DM events), processing each batch via processRelayEvents() directly
  2. Second pass (friend events): Paginates through POST /federation/sync with contextType: 'friend', same direct processing
  3. Third pass (profile events): Paginates through POST /federation/sync with contextType: 'profile', same direct processing
  4. 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)

  1. Gets connected instances from instanceStore
  2. Fires Promise.allSettled() with:
    • Home instance: api.social.friends()
    • Each connected remote instance: inst.api.social.friends()
  3. Deduplication by canonical identity: Uses Map<string, number> keyed by friend.homeUserId ?? friend.id. First occurrence wins, but native profiles replace replicated stubs: a native profile (homeInstance is 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' homeUserId to their own id so federation tier-1 lookups succeed (see federation.ts:backfillHomeUserId), so homeUserId is set on natives too.
  4. Asset normalization: For remote-origin friends, calls normalizeUserAssets(friend, origin) to resolve relative avatar/banner URLs to absolute remote URLs
  5. 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)

  1. Fires parallel searches to home + all connected instances
  2. Deduplication by canonical identity: Uses Map<string, number> keyed by user.homeUserId ?? user.id
    • First occurrence wins, but native profiles replace replicated stubs: if a native profile (homeInstance is 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 have homeUserId backfilled to their own id by the server so federation tier-1 lookups succeed (federation.ts:backfillHomeUserId). homeInstance is 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 _instanceOrigin tag) rather than a replicated stub whose origin would be the caller's home instance

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

  1. Fires Promise.allSettled() to home + all connected instances' api.social.discover(query)
  2. Tags each user with _instanceOrigin
  3. 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)
  4. Sums total from all instances
  5. 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:

  • UserDiscoverCard after 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:

  1. Computes canonicalHomeId = targetHomeUserId ?? targetUserId
  2. Fires api.users.getMutuals(targetUserId, canonicalHomeId) to home + all connected instances
  3. Friend dedup: By canonical identity friend.homeUserId ?? friend.id (prevents the same friend appearing from multiple instances)
  4. Space dedup: By ${space.id}:${origin} (spaces on different instances are distinct entities)
  5. 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):

  1. Same id -> true
  2. user.id in _knownSelfIds set (populated from WS ready events) -> true
  3. user.homeInstance === window.location.host AND base username matches -> true

canonicalUserMatch(a, b)

Federation-safe identity comparison with cascading strategies:

  1. Same id -> true
  2. homeUserId cross-matching: a.homeUserId === b.homeUserId, or a.homeUserId === b.id, or b.homeUserId === a.id -> true
  3. 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

The Add Friend tab merges search and discovery into a single UI:

  1. Empty query: Shows discover grid (from discoverStore.fetchUsers(), loaded on mount)
  2. Query entered: Switches to search mode (debounced 300ms, uses socialStore.searchUsers())
  3. 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 calls sendFriendRequest(query.trim()) — the resolved form is display-only. All routing, peering, and remote lookup happen server-side on POST /api/social/requests (see §6 outbound flow). Server-side POST /api/social/requests lowercases 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' with requestId
  • If inbound pending request -> relationship: 'inbound_pending' with requestId
  • 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" buttons
  • friends: "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 via mapServerErrorToMessage

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;
}