Navigation

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 "'s Room", the member editor's heading, the invite list's creator, and β€” through 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 through getMyUserToRoom / setMyUserToRoom; populated by readMyUsersToRooms and the onUpdateUserToRoom subscription. There is no per-user inner map here β€” nobody else's row is ever held. What the store exports under that name is useDataMap'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 by readNicknames (called per-room from readMetadata in useReadMembers) and the same subscription.

Procedures

Two focused procedures keep each member's private fields (notificationType, lastReadAt, lastMessageAt, isHidden) to themselves:

ProcedureInputReturnsWho
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

FileRole
apps/web/app/store/message/room/userToRoom.tsuseUserToRoomStore, getDisplayName
apps/web/app/composables/message/room/useCreator.tsmessage author, nickname already applied
apps/web/app/composables/message/useMessageHtml.tsmention 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.

Details

Command palette

Keyboard shortcuts