Navigation

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: null means "connected, no manual override", so the effective status is derived (getDetectedUserStatus) from isConnected + any manual override + expiry.
  • user.upsertStatus upserts the row (onConflictDoUpdate on userId) and emits upsertStatus on userEventEmitter; the onUpsertStatus subscription pushes live presence to members. user.readStatuses(userIds) batch-loads statuses for the member list.
  • Auto-idle: the client flips to Idle after autoIdleThresholdMs from 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; validates name (USER_NAME_MAX_LENGTH), biography, image.
  • Avatar upload uses the standard two-step SAS flow (file uploads) into AzureContainer.PublicUserAssets at {userId}/ProfileImage; rooms have the equivalent room profile image at rooms/{roomId}/ProfileImage.

In room contexts the displayed name goes through getDisplayName — the per-room nickname wins over the profile name (nicknames).

Key files

FileRole
packages/db-schema/src/schema/message/userStatusesInMessage.tspresence table + UserStatus enum
apps/web/server/trpc/routers/user.tsupsertStatus, 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)

Details

Command palette

Keyboard shortcuts