Esposter

Friends & Direct Messages

Friends are the only source of DM recipients — there is no separate messaging-permissions layer. Direct messages reuse the entire room/message infrastructure via RoomType.DirectMessage.

How it works

Relationship state is encoded by which table a row lives in — no status enum. All three tables use a deterministic natural key (getFriendshipId(senderId, receiverId), sorted ids joined by a separator), so duplicate sends conflict on the primary key and become no-ops, and any relationship check is an O(1) key lookup.

stateDiagram-v2
    None: no row anywhere
    Pending: friend_requests row
    Friends: friends row
    Blocked: blocks row

    None --> Pending: sendFriendRequest
    Pending --> Friends: acceptFriendRequest (transactional delete + insert)
    Pending --> None: declineFriendRequest
    Friends --> None: deleteFriend
    Pending --> Blocked: blockUser (deletes request)
    Friends --> Blocked: blockUser (deletes friendship)
    None --> Blocked: blockUser
    Blocked --> None: unblockUser

Blocking is unilateral, but a block in either direction prevents friend requests and excludes the user from searchUsers results. Self-relationships are rejected by database CHECK constraints, not just router validation.

The New Message dialog lists accepted friends; selecting one or more and confirming calls createDirectMessage, which finds or creates the DM room. Idempotency comes from rooms.participantKey — the sorted participant ids joined into one string with a unique index — so two users always share exactly one DM room. Group DMs are capped at 10 participants, show a generated name ("You, Alice, Bob") unless the creator sets one, and 1:1 DM names are derived at display time from the other participant so they never go stale. Hovering a DM row reveals a close action that soft-hides it (usersToRooms.isHidden = true) without deleting the thread.

DMs are invisible to non-participants: invite links are rejected for RoomType.DirectMessage and public room discovery excludes them. DM calls work like room calls (see /docs/esbabbler/calls); starting one posts a MessageType.Call system message in the thread.

Data model

TableKey facts
friendRequestspending only; id natural key, sender/receiver FKs, no-self CHECK
friendsaccepted only; same natural key; directionality preserved (who initiated)
blocks(blockerId, blockedId) PK; unilateral
roomstype (Room | DirectMessage), participantKey (unique, DM-only)
usersToRoomsmembership for both room types; isHidden soft-hide

Procedures

RouterProcedures
friendreadFriends, deleteFriend, searchUsers (excludes self + blocks)
friendRequestsendFriendRequest, acceptFriendRequest, declineFriendRequest, readFriendRequests
blockblockUser, unblockUser, readBlockedUsers
room (nested directMessage)createDirectMessage, readDirectMessages, readDirectMessageParticipants, createDirectMessageParticipants, deleteDirectMessageParticipant, hideDirectMessage

Key files

FileRole
packages/db-schema/src/schema/friendRequests.tsPending-request table
packages/db-schema/src/schema/friends.tsAccepted-friendship table
packages/db-schema/src/schema/blocks.tsBlock table
packages/app/server/trpc/routers/friend.tsFriend procedures
packages/app/server/trpc/routers/friendRequest.tsRequest lifecycle
packages/app/server/trpc/routers/block.tsBlocking
packages/app/server/trpc/routers/room/directMessage.tsDM creation, participants, hide
packages/app/app/pages/messages/friends.vueFriends management page
packages/app/app/store/message/user/friend.tsFriends client state (+ friendRequest.ts, block.ts)

Notes

  • DMs notify on every message by default — mention/keyword filtering is bypassed for RoomType.DirectMessage in the push path.
  • Re-adding someone to a group DM they hid works because their usersToRooms row is kept (soft-hide, not delete).