feat: user information redesign
feat: User information redesign
Supersedes #41591 — same branch history, recreated from an in-org branch (instead of a fork) so QA environments are generated.
The story
Today the room UI front-loads user data: the message header appends the @username to the real name, stacks every role as a tag, and clicking a name pops a card that duplicates most of what the contextual bar already shows. Information is repeated at every depth instead of being distributed along the journey.
This PR redesigns how and where user data is displayed, so each surface shows the right amount of information at a precise point, and more detail is revealed the deeper you go:
- Message header — identify. The minimum to recognize who is talking: the display name (no appended @username), one collapsed role tag per scope ("admin +2" workspace, "owner +1" room — full list in the tooltip), quieter timestamps. No tooltips on the name or avatar: hovering has a better answer now.
- User card — glance. Hovering the name or avatar (regular, thread and system messages) opens a card built to answer "who is this person, right now?": workspace roles band, presence, name and nickname, custom status, username, job title, room roles, local time — and the two actions you most likely need (Message, Video call), with everything else one kebab away.
- Full profile — dive. Clicking the name or avatar (or the card's "Full profile" link) opens the contextual bar, rebuilt as a contact card: everything the card shows plus bio, contact info, all profile fields, timestamps and management actions — each text info copyable, the avatar zoomable.
The same principle applies to the data itself: three new profile fields (title, nationality, languages) let workspaces enrich the deeper layers without adding noise to the shallow ones — the title surfaces on the card, the rest lives in the full profile.
The interaction grammar is consistent across every user reference: hover = glance (card), click = dive (full profile) — on author names, avatars and @mentions alike.
Screenshots
Message header — identify
Only the display name, one collapsed role tag per scope (full list in the tooltip) and a quieter timestamp. The @username and the six-tag pile are gone from the shallow layer.
| Before | After |
|---|---|
![]() | ![]() |
User card — glance (hover)
Before, clicking opened a card with a large avatar, icon-only actions and the bio. Now hovering opens a glanceable card: workspace roles band at the top, presence, nickname, job title, room roles, local time, a "Full profile" link — and labeled actions.
| Before (opened on click) | After (opens on hover) |
|---|---|
![]() | ![]() |
On your own card the second action becomes Edit (opens Account > Profile), and moderation actions are gone:
Full profile — dive (click)
Clicking the author name or avatar now opens the contextual bar directly, renamed "Full profile" and rebuilt as a contact card: card-style header, click-to-zoom avatar, the new profile fields (Title, Nationality, Languages), roles split by scope and copy-on-hover on every text info.
| Before ("User Info") | After ("Full profile") |
|---|---|
![]() | ![]() |
Members list
Only the display name when real names are enabled:
| Before | After |
|---|---|
![]() | ![]() |
Action menus — taxonomy
Fixed actions cover the likeliest intents (Message, Video call — or Edit on yourself); everything else lives in the kebab, grouped by purpose. Self-profiles never offer Mute, Ignore, Remove or Ban.
User card — someone else vs. yourself:
| Someone else | Yourself |
|---|---|
![]() | ![]() |
Full profile — same taxonomy on the contextual bar:
| Someone else | Yourself |
|---|---|
![]() | ![]() |
Members list — with no fixed buttons, Message / Edit / Reported messages form the first group, then role management:
| Someone else | Yourself |
|---|---|
![]() | ![]() |
How to review this PR
88 files, ~2k lines. The commits are thematic, but the diff reads best as five layers, each self-contained and building on the previous one. Suggested order, with the files that anchor each step and what to look at:
| # | Layer | Anchor files | What to check |
|---|---|---|---|
| 1 | Profile fields — data path (backend-first, independent of the UI) | packages/rest-typings/src/v1/users/*ParamsPOST.ts · apps/meteor/server/lib/users/saveUser/{handleProfileFields,validateUserData,saveUser}.ts · apps/meteor/server/meteor-methods/users/saveUserProfile.ts · apps/meteor/server/lib/utils/functions/getBaseUserFields.ts · packages/core-typings/src/IMeApiUser.ts · apps/meteor/lib/constants.ts | undefined = don't touch, null/"" = clear on every entrypoint; the REST schema and the server enforce the same raw-length limits; cleared fields reach clients as unset; /v1/me projects the fields (the account form seeds from it) · tests: apps/meteor/tests/end-to-end/api/users.ts |
| 2 | Profile fields — forms | apps/meteor/client/views/account/profile/{AccountProfilePage,AccountProfileForm}.tsx · apps/meteor/client/views/admin/users/AdminUserForm.tsx | dirty-field-only save (a status-only save no longer touches the profile), keepDirtyValues on both forms, { limit } validation messages, languages as a comma-separated text input |
| 3 | Message header + hover card (the redesign's core) | apps/meteor/client/components/message/MessageHeader.tsx + variants/* + header/MessageRoles.tsx · apps/meteor/client/hooks/{useUserRolesByScope,useRoomRolesQuery}.ts · apps/meteor/client/views/room/providers/UserCardProvider.tsx (+ spec) · apps/meteor/client/components/UserCard/* · apps/meteor/client/views/room/UserCard/UserCardWithData.tsx · packages/ui-contexts/src/UserCardContext.ts | the provider is the heart: non-modal popover, hover intent (500 ms open / 300 ms close, geometric tracking), capture-phase Escape that yields to open menus, trigger-to-trigger handoff vs. explicit dismissal, identity-stable context. Header: display name only, one collapsed tag per scope, author affordances step aside in selection mode. Lists inside the contextual bar pass hoverUserCardEnabled={false} |
| 4 | Full profile + action taxonomy | apps/meteor/client/components/UserInfo/* · apps/meteor/client/views/room/contextualBar/UserInfo/* · apps/meteor/client/views/room/hooks/useUserInfoActions/** · apps/meteor/client/views/room/contextualBar/RoomMembers/* · apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx | click = full profile everywhere; copyable text (word-break, touch reveal), zoomable avatar with an accessible name; actions grouped by purpose, self-aware (no Mute/Ignore/Remove/Ban on yourself), video call creating the DM on demand except for federated users |
| 5 | Mentions | packages/gazzodown/src/MarkupInteractionContext.ts · packages/gazzodown/src/mentions/UserMentionElement.tsx · apps/meteor/client/components/GazzodownText.tsx | same grammar as authors: hover = card (gated like the lists), click = full profile; room mentions still open the room |
Cross-cutting, read last: i18n keys in packages/i18n/src/locales/en.i18n.json and the lowercase formatter in TranslationProvider.tsx; e2e page-object updates under apps/meteor/tests/e2e/ (card opens on hover, profile on click, renamed panel).
Layer 1 is the only one that could ship on its own; 3 → 4 → 5 share the UserCardContext contract and the hover/click grammar, so they are best read in that order.
What changed per layer
Message header
- Display name only — the @username no longer renders next to the real name (nor in the members list when real names are enabled); it remains in the card and full profile
- One collapsed role tag per scope with a "Workspace roles: …" / "Room roles: …" tooltip; only the tag is clickable (opens the card)
- Author name in the
titles-labelstoken, underlining on hover when actionable; name/avatar tooltips removed - Timestamps in the
secondary-infotoken, on regular and system messages — the size stays on thec1type scale because the ramp has no 10px regular step (microis bold, which would make the timestamp compete with the name and defeat the point of quieter metadata); on hover the timestamp steps back up tofont-default, sincesecondary-infomisses WCAG AA (4.33:1) against the hover surface; quoted-message timestamps underline on hover since they are a jump action - The name button takes its accessible name from its visible contents, so integration messages with an
aliasare voice-control activatable (WCAG 2.5.3 — an earlieraria-label={displayName}diverged from the renderedalias || displayName)
These styles now live in the design system
The header styling above initially shipped as .rcx-* overrides in this PR, with matching fuselage PRs upstream. Those merged and released (fuselage#2138), so this PR bumps @rocket.chat/fuselage to 0.87.0 and deletes every local override — the design system is the single source of truth for the name emphasis, the interactive underline/cursor and the quieter timestamp with its hover contrast step-up.
The bump crosses two breaking fuselage releases; the fallout is absorbed here so the app upgrades cleanly:
@rocket.chat/fuselage-tokens0.34: artifacts moved underdist/,surface.jsonlost its wrapper object,breakpoints.jsonbecame an object keyed by name, typography'sfontScaleswas renamedfontScale— every consumer path in the app,pdf-worker,gazzodownandstorybook-configmigrated.ContextualbarV2family removed (fuselage#2137): the plainContextualbar*names now carry the V2 look;ui-client's wrappers renamed accordingly.Button'ssizeprop became a strict union: three components that spread raw HTML button attributes intoButtonnow exclude the conflicting HTMLsizeattribute.@rocket.chat/layout,onboarding-uiandlogobumped: their previous dists still required token files by pre-0.34 paths and crashed the client at runtime once the hoisted tokens copy became 0.34.- 78 DOM snapshots refreshed — all hash-only churn from fuselage's semantic-token colors and input pseudo-state updates; zero structural changes.
- Riding along in 0.87: message mentions render as plain text instead of pills (fuselage#2155) — consumed automatically via
MessageHighlight, no app change needed.
User card (hover)
- Opens on hover with intent delays (500ms open / 300ms close); collapsed role tags open it on click; Escape or hovering away dismisses it — no close button
- While open, hover is tracked geometrically (document
mousemoveagainst the card, trigger and spawned menu rects): syntheticmouseenter/mouseleaveproved unreliable on the portaled popover — the local-time clock re-renders every second under a resting pointer, firing spurious leaves - The popover is non-modal: a modal popover would run react-aria's
ariaHideOutsideand scroll lock, removing the entire page from the accessibility tree (including the focused element) the moment the pointer rests 500ms on any username — hostile to screen-reader users who navigate with a mouse. Escape is additionally handled at the document level because a hover-opened card never holds focus (WCAG 1.4.13) - The provider hands every trigger a static, identity-stable contract:
triggerPropscarries onlyaria-haspopup— one provider serves hundreds of triggers per room, so stateful attributes (aria-expanded/aria-controls) would light up on all of them whenever any card opened — and all context callbacks are stable, so memoized message headers don't re-render when a card opens elsewhere - Message avatars are explicitly decorative pointer-only duplicates (
aria-hidden) of the adjacent accessible name button — one tab stop per author, not two - Anatomy: workspace roles band ("Workspace roles" tooltip), 36px avatar, presence, name with inline lightweight nickname, custom status, icon-led info list (username, job title — truncated when long, room role tags, "3:04 PM local time (UTC -3)"), "Full profile" link
- Actions: Message and Video call fill the width, kebab with a permanent background sized to its neighbors; on your own card the second button is Edit (Account > Profile)
- Video call follows the room-header flow (
loadCapabilities→ outgoing popup, config warnings) and creates the DM on click when none exists — the same side effect Message has on navigation - Kebab taxonomy: Message / Edit / "Reported messages" (renamed from "View reported messages") form the first group, then a titled "Manage room roles" section, then the danger group (Remove from room, Ban)
- Self-profiles hide Mute, Ignore, Remove from room and Ban — on the card, the full profile and the members list
- The hover card is suppressed (
hoverUserCardEnabled=false) for messages rendered inside contextual bar tabs (search, pinned, starred, mentions): the card is as wide as the bar and would cover the very row it was opened from; click still dives into the full profile there
@mentions follow the same grammar
- Click (or Enter/Space) on a user mention opens the full profile — same as clicking the author name; hover opens the card, honoring the contextual-bar suppression above
- Channel mentions navigate to the room from any context (main chat or contextual bar)
- Why: an intermediate iteration had wired mentions to hover-only, which left pointer clicks dead (and touch waiting on a 500ms hover-emulation delay) and ignored the suppression flag inside the bar — both restored via the shared
useButtonPatternplus a dedicated hover handler in the markup interaction context
Full profile (click)
- Renamed from "User Info" to Full profile; clicking a message author's name or avatar (or Enter/Space) opens it directly, dismissing any pending card
- Card-style header: 48px avatar beside the name and presence, custom status underneath (shares the card's
UserCardUsernamecomposition) - Click-to-zoom avatar: a real button ("View avatar" tooltip and label) opening the full-size picture in the image gallery lightbox
- Username leads the fields; "Workspace roles" and "Room roles" split by scope (the latter only in a room context), both reactive
- Every text info (username, nickname, bio, title, nationality, languages, phone, email, voice extension, custom fields) reveals a copy-to-clipboard button on hover/focus — each announcing which field it copies ("Copy nationality", not eight identical "Copy" buttons), sentence-cased through a new i18next
lowercaseformatter so locales that capitalize nouns (e.g. German) can simply not use it - Long values (a 246-char languages entry, a long title) wrap inside the bar — breaking words if needed — instead of truncating to one unreadable line; the glance layer (card) truncates instead, matching each layer's intent
New profile fields — full stack
Three optional user fields — title, nationality and languages — editable in Account > Profile and in the admin user form (languages as a comma-separated input stored as an array), persisted by saveUserProfile (self) and the saveUser updater path (admin create/update), exposed through users.info and /v1/me, displayed in the full profile and (title) in the user card.
Because new persisted fields are an easy place to leak data-integrity bugs, the write paths were deliberately hardened:
- Limits enforced at every layer: the AJV REST schemas carry
maxLength: 260/maxItems: 20so oversized payloads die with a clean 400 before business logic; the same limits (shared constants, no duplicated magic numbers) are validated server-side and client-side with inline error messages - Validation runs before the account insert (
validateUserData), sousers.createwith an oversized field can no longer leave a half-created user behind - Absent ≠ clear: a partial
users.update(e.g. a roles-only integration call) leaves the three fields untouched; only an explicit empty value clears them — the pre-existinghandleBioconvention silently$unsetanything omitted, and these fields don't inherit that footgun /v1/meprojects the fields (getBaseUserFields+ theIMeApiUserresponse type): without this, the account form rendered blank inputs for values that existed in the database — and saving from that state erased them. The typed response also matters because the response validator (unevaluatedProperties: false) rejects undeclared fields under TEST_MODE, which would 400 the whole/v1/mein CI- The account form tracks the user record (react-hook-form
values+keepDirtyValues, like the admin form) and saves only dirty fields — a status-only save no longer rewrites every identity field; after saving, theusers.infoquery families are invalidated so an open card/profile reflects the change - Languages are normalized: trimmed, empties dropped, case-insensitive dedupe (
["English", "english "]stores once) - One write per save: the three fields accumulate into a single
updateOneinstead of one write (and one_updatedAtbump, and one change-stream event) per field - Covered by API tests: persistence with trimming, erasing via explicit empty values, both size limits, non-array rejection, admin parity and partial-update preservation
Pre-existing upstream bugs fixed along the way
Live role updates were broken for any consumer (message header tags, user card), not just this feature — a full reload was required to see role changes:
useRoomRolesQuery'sroles-changehandler kept the guard from the global-roles variant in itsremovedcase (!!scope), so every room-scoped role removal was silently ignored.- Both
useRoomRolesQueryanduseUserRolesQueryhandlers mutated the cached records in place (data[index] = ...) before returning a copy. react-query's structural sharing then compared the new data against the already-mutated old data, found them deep-equal, kept the old reference and never notified observers — the cache held the right value (hence reload "fixing" it) but the UI never re-rendered.
And one found while wiring the copy-button tooltips: i18next formatters never existed for English sessions. They were registered on the initialized event, but with English bundled in resources the init completes synchronously and the event fires before any listener attaches — so the pre-existing capitalize formatter only ever worked for locales loaded asynchronously (which is why pt-BR, its only consumer, masked the bug). Formatters are now registered right after init().
Keyboard support
Triggers (author name, system-message user and @mentions) are focusable buttons: Enter/Space opens the full profile; the collapsed role tags open the card, focus moves into the dialog, Escape closes it and returns focus to the trigger. A keyboard-opened card is not dismissed by stray pointer movement; a hover-opened card is dismissable with Escape from anywhere.
Accessibility
Beyond the WCAG 2.1 AA pass on the rendered components (contrast ≥ 4.5:1 everywhere including the quieter timestamps, real buttons with visible focus rings, description-list semantics with visually hidden terms), the branch was audited by four independent passes (data persistence, security, front-end, accessibility) before review, and every confirmed finding was fixed:
- non-modal hover popover (see the card section — the modal variant aria-hid the whole app on hover)
- no
aria-expanded/aria-controlsbroadcast to unrelated triggers - accessible names from visible contents on aliased messages
- distinguishable copy buttons, a named loading-card dialog, decorative avatars, error/hint
aria-describedbywiring on the admin form fields
Findings deliberately not taken (with rationale): the collapsed "+N" role tag stays pointer-only since the full role list is one Enter away in the full profile; nationality/languages ride the same users.info visibility as bio (documented product decision); the card's fixed 400px width is the design spec — flagged to design for the 320px reflow criterion.
Testing & verification
- Full jest suite green (287 suites / 2000+ tests, 108 snapshots)
- API tests for the new fields and admin parity, run against a live server (not just CI)
- e2e page objects and keyboard-navigation spec updated; federation page object fixed for the renamed panel
- Storybook stories updated for the new card anatomy and scoped MessageRoles API
- Every visual/behavioral claim above was verified against the running app with measured evidence: computed colors and stylesheet rules for the token migration, captured request payloads for the dirty-field gating, accessibility-tree checks for the popover modality and trigger attributes, and DOM-level checks for wrapping/truncation
Post-review QA pass
The branch was put through a full QA sign-off (account every commit and changed file, review high-risk first, validate hypotheses against a running server). It surfaced a handful of edge-case defects, all now fixed and covered by tests:
- Escape closed the card and an underlying contextual bar. The document-level Escape handler ran in the bubble phase without stopping the event, so a bar/search panel listening for the same key closed too. It now listens in the capture phase and consumes the event — verified in the real app (card closes, the full-profile bar stays open) and by a unit spec.
nullon the profile fields was inconsistent.title/nationalitywere coerced to""and cleared, whilelanguages: nullwas rejected. All three now treatnullas a valid "clear",nullableis declared on the schemas, and the field is unset on every write path.- The REST size limits and the server disagreed on the boundary. The schema checked the raw value while the server checked the trimmed/deduped one, so a value that would store fine could be rejected (and vice-versa across entrypoints). The server now validates the raw value too, matching the schema exactly on the ajv-guarded endpoints and the schema-less DDP method alike.
- UTC+0 users lost their Local Time. The
utcOffset && …guard short-circuits on a zero offset, so the full profile printed a stray "0" and the card dropped the row. Both now gate on the offset being an integer. - The admin user form could discard in-progress edits. It used react-hook-form's reactive
valueswithoutkeepDirtyValues, so the async SMTP query resolving reset the form — the same guard the account form already had. Added. - Dead code and a type hole removed. The card's keyboard-open machinery never ran (keyboard triggers open the full profile, a superset) and is gone; the DDP
saveUserProfiletype now declares the new fields.
Each fix ships with test coverage (API tests for null-clears and raw-length parity, a UTC+0 render test, and a spec proving the Escape is consumed), and the full jest suite stays green (288 suites / 2000+ tests). A cold-first-open flicker theory and the collapsed "+N" role tag's keyboard story were reviewed and deliberately left as-is.
AI reviewer pass
Taking the PR out of draft triggered CodeRabbit (8 comments), cubic (27) and Hacktron (security, clean). Every finding was checked against the code rather than taken at face value; seven were real and are fixed, the rest were duplicates of the follow-ups below, nits, or timing theories about the hover tracker that don't reproduce.
- Validation messages rendered the literal
{{limit}}. The develop merge broughtMax_length_isover from the positional%sform to a named placeholder, and the rules for the new fields still passed a bare number. They now pass{ limit }; the newMax_number_of_items_iskey follows the named form instead of adding another sprintf key. Verified in the account form: "Max length is 260" / "Max number of items is 20". - Role changes leaked across rooms. The
notify-loggedroles stream carries every room's changes, and the room-roles cache applied them all to whichever room it was mounted for — removing a role in one room dropped it from every other mounted room's cache for that user. The handlers now requirescope === rid. - Escape closed the card instead of its kebab menu. The capture-phase Escape from the QA pass also swallowed the keystroke meant for the card's actions menu. While a menu popup is open the event now passes through untouched and the menu dismisses itself; verified in the app (first Escape closes the menu and keeps the card, the second closes the card) and by a second unit spec.
- Author triggers fought the selection mode. In message selection the row is the click target, but the name and the collapsed role tag still opened the profile/card and then bubbled into toggling the selection. They now step aside while selecting, the same way the avatar already does (the name loses
role="button"/tabIndex; a click only toggles the checkbox — verified). - The thread list still opened hover cards. It has its own
MessageListProviderand missed the contextual-bar gate applied to search and pinned messages. Gated; verified on the Threads tab. - Administrators couldn't see the new fields. The admin user info rebuilt the user object field by field and left title/nationality/languages out, so the section it renders stayed empty for the one audience that edits them. Passed through; verified on
/admin/users/info. - Clearing a field didn't reach connected clients. Mongo unset it, but the
watch.usersevent either omitted it (saveUserProfilediffs the fresh document) or carried the rawnull/[]from the request (saveUserspreadsuserData).handleProfileFieldsnow reports what it cleared and both paths mirror that in the event'sunset, which closes the$unset-propagation follow-up for the profile fields.
Second look. The remaining findings were re-triaged one by one against the code (and in the browser where behaviour was involved), and eight more turned out to be worth fixing:
- Hover handoff between authors. With card A open, moving the pointer straight onto author B's name never opened B's card: the close path cleared every timer, including B's pending open. Only the close timer is cleared on dismissal now — reproduced before, verified after.
- UTC+0 on the OAuth authorization card — the third spot with the truthiness guard on
utcOffset. - Copyable text lost its
word-break(the reveal-on-hover class replacedInfoPanelText's own class), so a long unbroken bio overflowed the bar; and the copy button, revealed on hover, was undiscoverable on touch devices — it stays visible under@media (hover: none). Both verified via computed styles, the latter under mobile emulation. - Zoomed avatar had an empty accessible name —
ImageGalleryuses the image description as alt text; it now reads "Avatar of {username}". - Roles without a description vanished from the header and the card; they fall back to the role name (custom roles are often created without one).
- Video call offered to federated users without a DM. The create-DM-on-demand branch never checked federation the way the existing-room branch does; the
federatedflag is threaded through the card, the full profile and the members list. - A partial-update API test depended on the previous test's writes; it is self-contained now.
Two design calls were made deliberately: the shorter Mute/Unmute labels stay even though ~38 locales only translate the longer Mute_user form (they fall back to English there), and the collapsed "+N" role tag stays pointer-only. The rest of the findings — hover tracker timing theories that don't reproduce, srOnly duplication and the deprecated Roles store (both registered below), defense-in-depth validation on the server, a comma inside a language name — were left as-is on purpose.
Third look. The bots re-reviewed the fixes above and caught four consequences worth taking: explicit dismissals (Escape, the card's own close) now cancel a pending hover open — the handoff fix had let a card come back after Escape when the pointer rested on another author (covered by a spec that fails without the fix); the card no longer keeps the previous username's users.info response as placeholder, so a handoff renders a skeleton instead of the previous user's roles and actions; user.federated joined the video-call memo's dependencies; and the watch.users diff carries the persisted (trimmed, deduped) profile values instead of the raw request. The action icon on the card became an explicit prop to state that icon + label is intentional and data-driven per action. Every bot thread has a reply naming the resolving commit or the reason it was left as-is.
Registered follow-ups (out of scope)
- Unify the three
users.inforeact-query key families (pre-existing fragmentation; the save-time invalidation here covers the symptom) - Move role descriptions off the deprecated global
Rolesstore (the whole app reads from it today;useUserRolesByScopefollows the existing pattern) onto a query fed by the roles stream - Propagate bio/nickname
$unsets through the user stream the same way the profile fields now do (pre-existing pattern, out of scope here) - A fuselage
<VisuallyHidden>primitive (the app duplicatessrOnlycss) and an unstyled-button-with-focus-ring primitive aria-describedbywiring for the pre-existing bio/statusText admin fields- A 10px regular type-scale step, if design ever insists on 10px timestamps
🤖 Generated with Claude Code
Summary by CodeRabbit
-
New Features
- Redesigned user cards and full profiles with scoped roles, profile details, copyable information, avatar zoom, and clearer actions.
- Hovering over names, avatars, and mentions opens a user card; clicking opens the full profile.
- Added editable Title, Nationality, and Languages profile fields.
- Video calls can start from profiles when no direct-message room exists.
-
Bug Fixes
- Corrected role update and removal handling.
- Prevented users from moderating themselves.
- Improved local time display and profile updates.













