# Profiles Click someone anywhere in the lobby and their profile card opens, the way it does in Discord. This page covers the card, the full profile, the image viewer, the API behind them and the privacy and permission rules they follow. - [Where the card opens](#where-the-card-opens) - [What the card shows](#what-the-card-shows) - [What you can do from it](#what-you-can-do-from-it) - [The full profile](#the-full-profile) - [The image viewer](#the-image-viewer) - [The profile API](#the-profile-api) - [Profile fields](#profile-fields) - [Mentions](#mentions) - [Live updates](#live-updates) - [Privacy](#privacy) - [Permissions: `viewerCan`](#permissions-viewercan) - [Accessibility](#accessibility) - [For developers](#for-developers) - [Not done yet](#not-done-yet) ## Where the card opens | Place | How | |---|---| | Member list | Click a row. The card gets the row's live status. | | Chat | Click the author's avatar or name, or a mention chip (`@Ada`) in a message. Bots, webhooks and apps have their own badges and no card. | | Voice roster | Left-click a participant (shown with their photo). Right-click and the "⋮" button still open the moderator menu. | | Direct messages | Click the header (avatar and name), or an author's avatar or name. | | Your account block | Click your avatar to open your own card. Click your name to open the status menu, as before. | Only one card is open at a time. Clicking another person switches the card, and clicking the same person again closes it. A click anywhere else closes it too. A mention in a message opens the card of the member it names; see [Mentions](#mentions). ## What the card shows From top to bottom: - **Banner.** The member's banner. Without one, a band in their profile colour ([Profile fields](#profile-fields)); without that, the main colour of their avatar. The colour is sampled from the image on a small canvas. If that fails, the band takes the colour of their highest coloured role, then the theme accent (`--lf-user-accent`). Only same-origin avatars are sampled: an external image would taint the canvas. - **Avatar** with a status dot. The dot shows the status the member chose: Online, Idle, Do Not Disturb, or Offline. When you look at your own card, Offline reads Invisible, because that is what you picked. Being in voice is shown separately, as an "In voice" badge. - **Names**, Discord's way: the name this community sees (the nickname, if there is one) in large type, and the account's own name under it when a nickname replaces it. Under the names, `@handle · pronouns` when the member set them. Then a Guest badge, a crown for the community owner, and the custom status as a speech bubble. A profile colour also draws a thin ring at the edge of the photo. - **About me**: the bio, or "No bio yet.". - **Member since**: when the LobbyForge account was created and when they joined this community. Both dates are in the viewer's language (`Intl.DateTimeFormat`). - **Roles**: chips with each role's colour dot or icon, highest first, without `@everyone`. - **Mutual communities**: the count and the first few icons. This section appears only when you share a community other than this one, so on a single-community self-host it does not appear. - **Volume**: your local volume for this member, while they are in voice. - **Message @name**, and a **View full profile** link. The card opens at once with what the lobby already rendered: name, avatar, bio, status text and roles. Then it fills in from the profile API, with grey placeholders for the parts still loading. - **Status and "In voice"** come from the opener only when it knows them live. The member list knows the live status, and the voice room knows who is in it. Every other opener takes both from the API, because the page's own copy is stale. - **Cache.** Answers are cached per (community, member) for the browser session. The cache belongs to one viewer: it empties when someone else signs in on the same tab. - **Refreshing.** A cached answer shows at once. If it is older than 30 seconds, the card asks again in the background. It always asks again for your own card, since you may have just edited it. - **After a change** made through the card (roles, a timeout, a block), that one profile reloads. - **The member list follows.** Every fresh answer from the profile API (not one read from the cache) hands the member's roles to the lobby's member list, which is otherwise the page render's. A role added or removed in the card moves the member to the right role heading, with the right name colour, at once, instead of on the next page load (`memberRoles` in `ProfileCardProvider`). ## What you can do from it Every action goes through a route that already existed. The card only shows what [`viewerCan`](#permissions-viewercan) allows, and the route checks everything again. | Action | Route | Shown when | |---|---|---| | Message @name: type, press Enter | `POST /api/dm`, then `POST /api/dm/{id}/messages`. The conversation then opens in the centre column, titled with the account's name like the DM list. | `viewerCan.message`: not yourself, and not someone you blocked. If they blocked you, the DM route refuses with "You can't message this member." | | Copy user ID | — | always | | Block / Unblock | `POST /api/settings/me/blocks`, `DELETE …/{userId}` (BlockListProvider) | not yourself | | Add role (+), remove role (×) | `PUT /api/servers/{id}/members/{userId}/role` with the whole new set, `@everyone` kept | `viewerCan.manageRoles`, and only the roles listed | | Timeout / Remove timeout | `PUT /api/servers/{id}/members/{userId}/timeout` | `viewerCan.timeout` | | Kick | `DELETE /api/servers/{id}/members/{userId}` | `viewerCan.kick` | | Ban, with an optional reason | `POST /api/servers/{id}/bans` | `viewerCan.ban` | | Server mute / unmute | `POST /api/servers/{id}/channels/{ch}/members/{userId}/voice/mute` | `viewerCan.serverMute`, while the member is in your voice room | | Edit profile | link to `/settings/profile` | your own card | | Set your status | the voice provider's presence status | your own card | Timeout, Kick and Ban ask first. A timeout can last 60 seconds, 5 or 10 minutes, 1 hour, 1 day or 1 week. The admin Members page has no timeout control, so these are Discord's own lengths, all under the route's 28-day cap. The ban reason is optional and up to 512 characters, like the admin page's prompt. A refusal shows inside the dialog, or under the roles, in the viewer's language. The message comes from the route's `code` (`insufficient_rank`, `actor_timed_out`, `target_is_owner`, …), never from its English `error` (`lib/profile-action-errors.ts`). The kick, ban, role and timeout routes give their own refusals a code too (`unauthenticated`, `server_not_found`, `invalid_request`, `already_banned`, `role_above_actor`, `ungrantable_permissions`, …); the admin Members page says them the same way. A member who is kicked or banned leaves the member list at once, and a short notice says so. Because the role route replaces the whole set, the card reads the member's roles fresh right before each change and applies the edit to that set. A set built from an answer cached minutes ago would quietly undo a role that another moderator, or the admin Members page, gave in the meantime. ## The full profile "View full profile" (or the mutual communities row) opens a larger modal with: - the banner and avatar (both open the image viewer); - the names, the status and the custom status; - **About**: the bio, Member since and Roles, with the same role controls as the card; - **N mutual communities**: each community with its icon and name. Each one is a link to `/lobby?server={id}`, and the current one is marked "This community". The "⋯" menu holds the same actions as the card's. Arrow keys, Home and End move between the tabs. ## The image viewer `components/ImageLightbox.tsx` is a shared, generic viewer. Profiles use it for avatars and banners, and chat image attachments can use it later. - It shows the image centred on a dimmed backdrop, up to about 80 vmin, with a short zoom-in. There is no animation under `prefers-reduced-motion` or the app's own reduced-motion setting (`.lf-zoom-in` in `globals.css`). - Escape, a click on the backdrop, or the X closes it. Focus stays inside and then returns to the avatar or banner that opened it. - The image's alt text says whose avatar or banner it is. - "Open original" opens the same-origin image URL in a new tab. ## The profile API ``` GET /api/servers/{serverId}/members/{userId}/profile ``` The route uses `withApiSecurity`, needs a session and is limited to 120 requests a minute. The answer has `Cache-Control: no-store`. ```jsonc { "profile": { "userId": "…", "displayName": "Ace", // nickname, else the account's name "globalDisplayName": "Alice", "nickname": "Ace", // or null "avatarUrl": "/api/users/…/avatar?v=…", // versioned URL, or null "bannerUrl": "/api/users/…/banner?v=…", // versioned URL, or null "isGuest": false, "username": "ace", // the handle, or null — shown like a name "statusText": "Building", // custom status, or null "bio": "…", // or null "pronouns": "she/her", // or null (unset, or withheld when limited) "accentColor": "#5865f2", // profile colour, or null (unset, or withheld when limited) "presence": "idle", // online | idle | dnd | offline, or null "inVoice": false, // in a voice room of this community the viewer can see "roles": [{ "id": "…", "name": "Mods", "color": "#ff8800", "icon": "shield", "position": 10, "displaySeparately": true }], "memberSince": "2026-02-03T04:05:06.000Z", // or null "accountCreatedAt": "2025-01-02T03:04:05.000Z", // or null "mutualServers": [{ "id": "…", "name": "…", "iconUrl": null }], "mutualServerCount": 2, "blockedByMe": false, "limited": false, // details withheld: a hidden profile, or a block either way (which, is not said) "isOwner": false, // the member owns this community "timedOutUntil": null // ISO time, only for viewers who may time the member out }, "viewerCan": { "isSelf": false, "message": true, "kick": false, "ban": false, "timeout": false, "serverMute": false, "manageRoles": false, "assignableRoles": [], // roles the viewer may add, highest first "removableRoleIds": [], // roles the viewer may remove "currentRoleIds": [] // every role held, @everyone included (the role PUT replaces the set) } } ``` **Access.** The viewer must be the owner or a current member of the server, and the subject must be a current member: a live account, a live server, and no active ban (a membership row that outlived a ban does not count). If any of this fails, or the ids are malformed, the answer is the same 404. The route can't be used to find out who belongs where. - **Roles** are the ones the member holds in this server: the join table plus the legacy primary role, highest first, without `@everyone` and without their permissions. `displaySeparately` says whether online holders get their own heading in the member list. - **Presence** goes through the same projection as `GET /api/presence` (`applyPresencePrivacy`). A hidden online status, a heartbeat older than 90 seconds, no heartbeat at all, or the Invisible choice all read as `offline`. If Redis is down, presence also reads `offline`, not an error. - **In voice** follows the member list's rule. The member must be in a voice or stage channel of this community that the viewer can see, with a status the viewer may see. A role-gated room the viewer can't see counts as not in voice. - **Mutual communities** are the communities both people currently belong to, on a live server, where neither has an active ban. There is no list for your own profile. The answer names up to 50 of them, and the count covers all of them. Icons are passed only as https URLs, never as data URLs. The viewer is a member of every community listed, so the list reveals nothing new. The database half lives in `packages/db/src/queries/memberProfiles.ts`: `getMemberProfileRecord` and `listMutualServers`. ## Profile fields Migration 0050 adds three columns to `users`, edited in Settings → Profile and shown on the card. All three are nullable: every account from before 0050, and every guest, simply has none. | Field | Rules | Shown | |---|---|---| | **Handle** (`username`) | 2–32 of `a-z 0-9 _ .`, stored lowercase, unique (a unique constraint plus a format CHECK). No leading, trailing or doubled dot. Reserved: `everyone`, `here`, `admin`, `administrator`, `moderator`, `mod`, `owner`, `system`, `lobbyforge`, `support`, `staff`, `bot`, `webhook`. Guests can't take one. | `@handle` under the names, always (a name, like the display name). Typed in chat it is a mention. | | **Pronouns** | Free text, at most 40 characters, no control characters, whitespace collapsed. | Next to the handle — withheld like the bio. | | **Profile colour** (`accent_color`) | `#rrggbb`, stored lowercase (a CHECK). Twelve swatches, any colour from the picker, or Automatic. | The card's band without a banner, and the ring around the photo — withheld like the bio. | The settings page checks a handle while you type: the format rules at once, then `GET /api/users/me/username?name=…` after a short pause (60 a minute; handles are visible to fellow members anyway). The page shows a preview of the band, the photo ring, the name and `@handle · pronouns`. `PATCH /api/users/me/profile` takes `username`, `pronouns` and `accentColor` next to the old fields (`null` or an empty string clears one). Refusals carry a code the page translates: `username_too_short`, `username_too_long`, `username_characters`, `username_dots`, `username_reserved`, `username_guest` (403), `username_taken` (409), `pronouns_too_long`, `pronouns_characters`, `accent_color_invalid`, `invalid_profile`. **Community nicknames.** Settings → Profile → "Server-specific" lists every community the account is in and edits the nickname of the one picked; the profile card's "Edit profile" opens it on the card's community (`/settings/profile?server=…`). `PATCH /api/users/me/server-profile` takes `serverId` and checks the membership (`409 no_community` otherwise). Before, the nickname always went to the account's newest community, whatever lobby it was set from. ## Mentions A message's text stays plain (`hi @Ada Lovelace`), so bots, notifications and older clients read it as before. Who a mention names is kept beside it, in `metadata.mentions`: `[{ "userId": "…", "text": "Ada Lovelace" }]`, where `text` is what follows the `@`. Only the server writes it (security-review AUTHZ-008 still refuses client metadata): - the composer sends `mentions: [userId…]` — the members picked from the `@` list whose name is still in the text; - the messages route (post and edit) keeps a pick only if it is a current member whose nickname, account name or handle stands as `@…` in the text, and adds every typed `@handle` of a current member (`lib/message-mentions-server.ts`). One entry per member and spelling: a member picked as `@Ada Lovelace` and also typed as `@ada_l` is two chips (and still one notification). An edit keeps the earlier mentions whose names are still there. At most 20. The lobby turns each stored `@text` into a chip that opens that member's card, marks `@everyone` / `@here`, and highlights a message that names the viewer (or pings everyone) with an amber bar, as Discord does. The `@` list matches a handle too, and folds case the viewer's way (Turkish `İ`/`I`). **Notifications.** Each member a new message names hears it on their own feed (`user:{uid}`, `{ type: "mention", serverId, channelId, messageId, fromUserId }`, `lib/mention-notify.ts`) — only if they may read that channel, never the author, never someone who blocked the author, and with no text. The channel list gives that channel a red badge with the count until it is opened, and a hidden tab gets a desktop notification. Settings → Notifications now does what it says: "Unread badge" turns the badges on or off, "Suppress while in voice" keeps a voice room quiet, and the sound choice plays the page's own chime, a soft pop, or nothing (`lib/notification-prefs.ts`). ## Live updates The lobby's member rows used to be the page render's: someone who joined, a new name or avatar, roles handed out by somebody else — all waited for a reload. They now arrive live. - **`members:{serverId}`** is a new gateway topic (any member may subscribe; a kick, ban or role change re-checks it). Its events say WHO changed, never what, and the gateway forwards nothing but these three shapes (`apps/ws-gateway/src/member-events.ts`): - `members-changed { userIds }` — joined, or their profile, nickname, roles or timeout changed; - `members-removed { userIds }` — left, kicked or banned; - `roles-changed` — a role's name, colour, icon, grouping or order changed, or a role was deleted. - `lib/member-bus.ts` publishes them from every route that changes those things (join paths, kick and leave, ban, timeout, role assignment, role edit and delete, nickname, and the account's own profile, avatar and banner — which reach every community it belongs to). - `LiveMembersProvider` re-reads exactly the named rows from `GET /api/servers/{id}/lobby-members?ids=…` (projected for the viewer like the page render; owner or member only; up to 100 ids; `missing` lists those no longer members), or everyone after `roles-changed` or when the topic goes live. The member list, the profile card (an open card re-reads), chat author names, the mention list and the voice roster all read these rows; status stays the presence feed's. - **DMs.** A DM message also tells both participants' feeds (`{ type: "dm_activity", channelId, fromUserId }`, no text). The sidebar's DM list re-reads `GET /api/dm`, so a conversation someone else started appears, and one that is not open gets an unread dot until it is opened. The dot does not depend on catching the event: each read after the first compares with the one before, and a conversation that moved with the other person's message newest (`lastMessageFromOther` in the answer) is marked too — one that arrived while the socket was down, for instance. - **Going live.** The gateway keeps no backlog and replays nothing, so a change made between the page render and the subscription — or while the socket was down — never arrives as an event. Every live surface (member rows, the DM list, an open channel or DM, an activity) passes `onLive` to `RealtimeClient.subscribe`: it is called when the gateway confirms the topic on a socket (the first time, and after every reconnect) and at once for a subscriber joining a topic that is already live, and the surface re-reads what it shows then. A rename made a moment after another member opened the lobby used to stay stale on their screen until a reload. ## Privacy The route uses the lobby's own projection: `canViewProfile` and `projectMemberProfile` in `lib/profile-privacy.ts`, the same functions that build the member list. Nothing the lobby would hide comes through the API. | | Full profile | Hidden profile (`profileVisibility` shuts the viewer out) | They blocked you | You blocked them | |---|---|---|---|---| | Names, handle, Guest tag, roles, owner crown | yes | yes | yes | yes | | Avatar | yes | no | no | if the privacy setting allows (the block list's rule, `projectBlockedUser`) | | Banner, bio, custom status, pronouns, profile colour | yes | no | no | no | | Member since, account created | yes | no | no | no | | Mutual communities | yes | no | no | no | | Presence, in voice | per presence privacy | per presence privacy | per presence privacy | no (like the presence feed) | | Message @name | yes | yes | yes (the DM route refuses) | no | | `limited` | false | true | true | true | - The viewer always shares this server with the member, so "Server members" passes. "Nobody" shuts out everyone but the member themselves. Owners and moderators get no bypass, the same as the member list. - A block hides the payload in both directions. - A member who blocked you gets exactly the answer a hidden profile gets, so the card is no way to find out you were blocked. `blockedByMe` is only your own block, which your block list already shows. - **Two places still give a block away**, and this change does not add a third: - the DM route answers a different refusal in each direction; - the member list still shows the avatar and bio of someone who blocked you, because the lobby projects privacy, not blocks. Closing these is a next step. - A block doesn't change what a moderator may do. `viewerCan` is about the viewer's rights, not the member's data. ## Permissions: `viewerCan` `lib/member-capabilities.ts` works out `viewerCan` with the same rules as the moderation gate, `authorizeModerationTarget` in `lib/member-authorization.ts`. The gate now imports its permission map and rank comparison from this module, so the two can't drift apart. 1. The viewer must be a member, or the owner. 2. The viewer needs the action's permission: Kick Members, Ban Members, Moderate Members (timeout), Manage Roles, or Mute Members (server mute). The owner holds every permission. 3. A timed-out viewer may do nothing. This never applies to the owner. 4. No one may act on themselves, and no one may act on the owner. 5. The owner may act on anyone else. Everyone else must rank strictly above the member: equal rank can't act on equal rank. Role assignment adds the role route's own rules. A non-owner may hand out only roles strictly below their highest role, and only roles whose permissions they could grant themselves. Nobody but the owner can grant `administrator` (`lib/role-grant-policy.ts`). `@everyone` is never offered and never removed. ## Accessibility - The card is a dialog named "{name} profile". Focus moves into it when it opens, stays inside on Tab and Shift+Tab, and returns to whatever opened it when you close it with Escape or the X (`lib/use-focus-trap.ts`). Escape closes the card even when focus is somewhere else, unless another dialog or menu has it. Until the card is placed it is only transparent, never `visibility: hidden`, so it can take focus as soon as it opens. - The full profile, the image viewer and the confirmations are modal dialogs with the same focus handling. Escape closes only the topmost one. In the "⋯" menu and the role list, Escape closes only that menu or list. - Every action works from the keyboard. The menus use arrow keys, Home and End, and the role list also uses Enter. The chat avatar is a second way to the same card as the author's name, so it is left out of the tab order and hidden from screen readers. - Status is never shown by colour alone: the dot has a label, and the name block says the status in words. ## For developers | File | What | |---|---| | `apps/web/app/api/servers/[id]/members/[userId]/profile/route.ts` | the API | | `packages/db/src/queries/memberProfiles.ts` | one member's record, mutual communities | | `apps/web/lib/member-capabilities.ts` | `viewerCan`, shared with the moderation gate | | `apps/web/lib/member-profile.ts` | response types, the session cache, merging seed and answer | | `apps/web/app/lobby/ProfileCardProvider.tsx` | the lobby's one card, `useProfileCard()`, `ProfileTrigger` | | `apps/web/components/modals/UserProfilePopover.tsx` | the card | | `apps/web/components/modals/FullProfileModal.tsx` | the full profile | | `apps/web/components/modals/profile/*` | shared pieces, the "⋯" menu, the confirmations, the actions | | `apps/web/components/ImageLightbox.tsx` | the image viewer | | `apps/web/lib/profile-accent.ts` | the banner band's colour | | `apps/web/lib/profile-fields.ts` | handle, pronouns and colour rules (page and route) | | `apps/web/lib/mentions.ts`, `lib/message-mentions-server.ts`, `lib/mention-notify.ts` | mention parsing, resolution, notifications | | `apps/web/lib/member-bus.ts`, `app/lobby/LiveMembersProvider.tsx` | live member rows | | `apps/web/app/api/servers/[id]/lobby-members/route.ts` | member rows for the live lobby | | `apps/ws-gateway/src/member-events.ts` | what a `members:` subscriber may receive | To make a new place open the card, wrap the person in ``. Outside the live lobby (no provider) it renders the same content without the button. Tests: - `app/api/servers/[id]/members/[userId]/profile/__tests__/profile.test.ts`: access, privacy, blocks, presence and `viewerCan`; - `lib/__tests__/member-capabilities.test.ts`: `mayModerate` against the real gate over every combination of facts; - `packages/db/src/__tests__/member-profiles*.test.ts`: unit tests, plus an integration test that runs with `TEST_DATABASE_URL`; - `components/modals/__tests__/UserProfilePopover.test.tsx`, `components/__tests__/ImageLightbox.test.tsx` and `app/lobby/__tests__/ProfileCard.test.tsx`; - `e2e/profile-card.spec.ts`, which runs against the compose stack. ## Not done yet - **Unread marks for every message.** Channels show a badge for mentions of the viewer; a mark for any new message needs a per-viewer "channel activity" feed that respects channel visibility. The old dot (lit for a channel merely opened in the last seconds) is gone. - **Mentions in DMs, from bots, of roles; `@everyone` notifications.** Member mentions in community channels only; an edit that adds a mention does not notify (Discord doesn't either). - **Image viewer for chat attachments.** The viewer is generic; chat has no image attachments to open yet. - **A DM with someone who is not in this community** opens a card with just the name and avatar from the DM. The profile API is per community. - **The two other places that give a block away**: the DM route's two refusals, and the member list's profile fields (see [Privacy](#privacy)). - **A role-set precondition.** The role route has no `expectedRoleIds` check (answering 409 on a mismatch). The card works around this by reading the set fresh before each change, but two changes at the very same moment could still race.