- 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
33 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)
Simpler than discover: LIKE match on username or displayName, excludes self, limit 10. Returns User[] (no mutual counts, no relationship enrichment). Does not filter by discoverable or exclude replicated users.
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 (origin instance -- social.ts:POST /api/social/requests):
- Friend request created locally (DB insert + WS broadcast to local recipient)
- Build identity objects for sender and recipient using
homeUserId || id/homeInstance || getOurOrigin() - Call
getFriendEventTargets(from.homeInstance, to.homeInstance)-- if both local, returns[](no relay) - Build
FederationRelayEventwitheventType: 'friend_request_create',contextType: 'friend', friendship payload including both identities and profile snapshots - Call
appendMutationLog()-- records infederation_mutation_logfor sync protocol - Call
queueOutboxEvent()-- inserts intofederation_outboxfor each target peer
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) - 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 -> reject) - Idempotency checks: If already friends -> accept as no-op. If pending request already exists from same sender -> accept as no-op.
- 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
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 new peer is established (federation_peers.lastSyncedAt = 0), the federation worker runs runInitialSyncForNewPeers() on startup. This includes a dedicated friend sync pass.
Flow (federationWorker.ts:runInitialSyncForNewPeers):
- Query all active peers with
lastSyncedAt = 0 - First pass (DM events): Paginates through
POST /federation/syncwith nocontextTypefilter (defaults to DM events), relaying each batch through the local/api/federation/relayendpoint - Second pass (friend events): Paginates through
POST /federation/syncwithcontextType: 'friend', same relay-to-self pattern - After both passes complete, updates
lastSyncedAt = Date.now()so the sync doesn't repeat
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: Uses a
Set<string>keyed by${friend.id}:${origin}-- prevents duplicates within the same instance - 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, with same dedup key: ${request.id}:${origin}. Normalizes assets for remote request user profiles.
Sending Friend Requests (Federation Routing)
sendFriendRequest(username: string) handles the user@domain routing:
- No
@in username: Send to home instance API directly @present: ExtractbaseNameanddomainvialastIndexOf('@')- If domain matches
window.location.host: strip domain, send to home API - Otherwise: find matching connected instance by comparing
new URL(inst.origin).host === domain- Not found: Throw
InstanceNotConnectedError(domain)(UI prompts to connect) - Found but disconnected: Throw
InstanceDisconnectedError(domain)(UI prompts to reconnect) - Found and connected: Send to remote instance API with just the
baseName(no domain suffix)
- Not found: Throw
- If domain matches
- 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 (
homeUserIdis null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry - This ensures the user sees the "real" profile rather than a replicated copy
- 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 field on matching friend by ID (all origins) |
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 "youruser@nova.ddns.net" into { baseName: "youruser", 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 detection: If query contains
@(at non-edge position), shows a "Send friend request to user@domain" action row
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. Handles InstanceNotConnectedError / InstanceDisconnectedError by showing ConnectInstanceModal.
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)which handlesuser@domainrouting ConnectInstanceModalshown when trying to add a friend on an unconnected instance
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;
}