Profiles & Presence
Who a user appears as (profile card, biography, avatar) and whether they appear available (status, custom status message).
Presence
userStatuses table (Postgres), one row per user: status (nullable enum), isConnected, message (custom status), expiresAt.
UserStatus:Online | Idle | DoNotDisturb | Offline— nullable:nullmeans "connected, no manual override", so the effective status is derived (getDetectedUserStatus) fromisConnected+ any manual override + expiry.user.upsertStatusupserts the row (onConflictDoUpdateonuserId) and emitsupsertStatusonuserEventEmitter; theonUpsertStatussubscription pushes live presence to members.user.readStatuses(userIds)batch-loads statuses for the member list.- Auto-idle: the client flips to Idle after
autoIdleThresholdMsfrom user settings.
The stored row is what the user asked for, never what anyone is shown: getDetectedUserStatus collapses the manual override, its expiry and isConnected into one status on the way out, so an override that has expired stops applying with nothing having to rewrite the row. Both exits derive — the emit and the batch read — so a client never sees a raw row and has no rule of its own to keep in step.
flowchart TD
Idle["idle timer passes autoIdleThresholdMs"] --> Proc
Manual["user picks Online / Idle / DND, with an optional expiry"] --> Proc
Socket["connection opens or drops"] --> Proc
Proc["user.upsertStatus — onConflictDoUpdate on userId"] --> Row[("userStatuses — status, isConnected, message, expiresAt")]
Row --> D1["getDetectedUserStatus"]
Row -->|"user.readStatuses on member-list load"| D2["getDetectedUserStatus"]
D1 -->|"userEventEmitter, onUpsertStatus"| Client
D2 --> Client["every member's client — the presence dot and its custom message"]
Profile
- Profile card — avatar, display name, biography; shown from the member list and message avatars.
- Editing — the bottom-left user panel opens the edit dialog:
name, biography, avatar upload, with a preview of the card as others see it. users.biography— text, max 160 chars (USER_BIOGRAPHY_MAX_LENGTH), shown beneath the display name.user.updateUser— restricted to the caller's own row; validatesname(USER_NAME_MAX_LENGTH),biography,image.- Avatar upload uses the standard two-step SAS flow (file uploads) into
AzureContainer.PublicUserAssetsat{userId}/ProfileImage; rooms have the equivalent room profile image atrooms/{roomId}/ProfileImage.
In room contexts the displayed name goes through getDisplayName — the per-room nickname wins over the profile name (nicknames).
Key files
| File | Role |
|---|---|
packages/db-schema/src/schema/message/userStatusesInMessage.ts | presence table + UserStatus enum |
apps/web/server/trpc/routers/user.ts | upsertStatus, readStatuses, updateUser |
apps/web/app/components/User/ProfileCard/ | editable profile card (global surface) |
apps/web/app/components/Message/Model/User/ProfileCard/ | in-room profile card (mutual rooms, member actions) |