Per-Room Nicknames
Members can set a per-room display name that overrides their global username within that room.
The resolution rule
Every member name is resolved through the one function the esbabbler skill names (getDisplayName), never user.name or member.name read directly in a room context.
WRONG β {{ member.name }} (ignores room nickname)
CORRECT β {{ getDisplayName(member, roomId) }} (nickname, falls back to global name)
The one name a template may render directly is creator.name from useCreator, because that composable has already resolved it β see below.
Nicknames are stored as text().notNull().default("") β empty string means "no nickname set", never null. Empty string is falsy, so fallback uses ||, never ??:
// WRONG β empty string passes through, user sees blank name
getNicknameMap(roomId)?.get(user.id) ?? user.name;
// CORRECT
getNicknameMap(roomId)?.get(user.id) || user.name;
Applied in: mention labels (useMessageHtml), the member list sidebar (Member/List), the profile card that pops out of it (ProfileCard/Index), the room settings Members panel (Settings/Type/Member/ListItem), the push notification title (queried server-side before the EventGrid publish), a room call's tiles, strip and action menu (resolved once in the two call composables every call surface reads β a standalone call has no room, so it keeps the account's name), the typing broadcast (resolved server-side), an unnamed room's "useCreator β every message in the timeline.
useCreator resolves the nickname itself, so every surface that renders a message author β avatar initials, the batch header, reply titles, forward and pin lines, the delete and pin confirmations β gets it from one place rather than each remembering to call getDisplayName. It reads the room from the message's own partitionKey, so a message rendered outside the current room β a search hit, a thread preview β resolves against the room it was written in rather than the room on screen. A webhook message is the one exception: its author is an app user, which is not a member of the room and therefore has no nickname to overlay.
The resolution is a single ordered chain, and every surface enters it at the same point:
flowchart TD
Render["a surface needs an author's name"] --> Creator["useCreator(message)"]
Creator --> Webhook{"message.type is Webhook?"}
Webhook -->|"yes"| AppUser["appUserMap β app user name, no nickname"]
Webhook -->|"no"| User["userMap.get(message.userId)"]
User --> Display["getDisplayName(user, message.partitionKey)"]
Display --> Nickname{"nicknameMap has a non-empty entry?"}
Nickname -->|"yes"| Nick["the room nickname"]
Nickname -->|"no"| Global["user.name"]
Data model
One column: usersToRooms.nickname β text, NOT NULL DEFAULT "", max 32 chars.
Client state β useUserToRoomStore
Two internal maps, both keyed by roomId, deliberately split for privacy:
myUserToRoomβMap<roomId, UserToRoomInMessage | undefined>β the current user's own full row for that room (notificationType, lastReadAt, isHiddenβ¦), reached per room throughgetMyUserToRoom/setMyUserToRoom; populated byreadMyUsersToRoomsand theonUpdateUserToRoomsubscription. There is no per-user inner map here β nobody else's row is ever held. What the store exports under that name isuseDataMap's current-key ref, not the map β one row, the room in view's β so it is named for the row rather than for the map behind it.nicknameMapβMap<roomId, Map<userId, string>>β all members' nicknames (including self); populated byreadNicknames(called per-room fromreadMetadatainuseReadMembers) and the same subscription.
Procedures
Two focused procedures keep each member's private fields (notificationType, lastReadAt, lastMessageAt, isHidden) to themselves:
| Procedure | Input | Returns | Who |
|---|---|---|---|
readNicknames | { roomId, userIds[] } | { userId, roomId, nickname }[] | All listed members |
readMyUsersToRooms | { roomIds[] } | Full UserToRoomInMessage[] | Current user only |
readMyUsersToRooms is called from useReadRooms to pre-load the current user's private settings (unread state, notification type) for all rooms at startup.
Setting your nickname
Room settings β My Profile tab (visible to all members, no permission required). Text field pre-filled with the current nickname; empty = no nickname. Saves via updateUserToRoom({ roomId, nickname }); the update propagates via the onUpdateUserToRoom subscription into both maps.
Key files
| File | Role |
|---|---|
apps/web/app/store/message/room/userToRoom.ts | useUserToRoomStore, getDisplayName |
apps/web/app/composables/message/room/useCreator.ts | message author, nickname already applied |
apps/web/app/composables/message/useMessageHtml.ts | mention label resolution |
apps/web/app/components/Message/Model/Room/Settings/Type/Profile/ | My Profile tab + nickname field |
Notes
Setting other members' nicknames via the ManageNicknames permission bit is not wired to a UI yet β the bit exists in RoomPermission (see RBAC) but only self-service nickname editing is built.