fix(dm): surface dm.name on chat header/placeholder; collapse unnamed-group placeholder
Three layered bugs all manifesting as "the group name doesn't show / the
placeholder is a 40-character wall of names":
1. **WS ready payload was missing `name`/`icon`.** The handler serialized
DmChannel rows with only `id, federatedId, ownerId, createdAt, members,
lastMessage`. The optional metadata fields were silently dropped, so
the client store never received `dm.name` until a subsequent
`dm_channel_updated` event fired (i.e. only mid-session renames worked,
never the initial render). `ownerHomeUserId`, `ownerHomeInstance`, and
`metadataUpdatedAt` were also missing — added too because federated
routing depends on `ownerHomeInstance` (`getDmOwnerHomeInstance`).
2. **Header surfaces silently dropped `dm.name`.** `MainContent` (desktop
chat header) and `MobileChatScreen` always rendered the joined member
names, even when `dm.name` was set. Five other surfaces (`DmListItem`,
`MobileDmsScreen`, `MessageList` welcome hero, `MobileGroupDmInfo`,
`GroupDmSettings`) honored it correctly, so a renamed group showed
different titles depending on which surface you looked at.
3. **Message-input placeholder rendered joined names.** Once a group
has 4+ members "Message #Alice, Bob, Charlie, Dave" overflows the
textarea and obscures the call-to-action.
Consolidates the display-name logic behind two utilities in
`dmFormatters.ts`:
- `formatDmHeaderName(dm, currentUser)` — `dm.name` verbatim if set,
else joined names (excluding self); falls back to `'Group'` /
`'Direct Message'`. Used by all 5 header surfaces (was inlined
5 different ways).
- `formatDmInputLabel(dm, currentUser)` — `'#<name>'` if set,
`'the group'` for unnamed groups (collapses the unreadable
joined-names form), `'@<partner>'` for 1-on-1.
`MessageInput` accepts an optional `placeholder` prop that bypasses the
default `Message {#|@}<channelName>` derivation; DM call sites use it
to inject the `formatDmInputLabel`-based form. 1-on-1 DMs keep the
canonical-view lookup so replicated aliases still surface the home
account's displayName; the placeholder reuses the canonical `dmName`
so header + placeholder stay aligned even when raw partner ≠ canonical.
13 new unit tests covering `formatDmHeaderName` (8 cases: named, blank,
joined, federated-username base, empty group, 1-on-1, no-displayName,
no-partner) and `formatDmInputLabel` (4 cases: named, unnamed,
whitespace-only, 1-on-1). 365 → 377 web tests, 1053 server tests,
typecheck clean.
This commit is contained in:
@@ -248,3 +248,69 @@ export function formatDmTimestamp(createdAt: number): string {
|
||||
// Previous year — "Dec 14, 2025"
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ─── DM Display Names ─────────────────────────────────────────────────────────
|
||||
|
||||
type AuthLike = { id: string; username: string; homeInstance?: string | null } | null;
|
||||
|
||||
/** Other-side member resolution, identical to every other DM display path. */
|
||||
function otherMembersOf(dm: DmChannel, currentUser: AuthLike): User[] {
|
||||
return dm.members.filter(m => !isSelf(m, currentUser));
|
||||
}
|
||||
|
||||
/** Member's visible name — display name if set, else the parsed base of the username. */
|
||||
function memberDisplayName(m: User): string {
|
||||
return m.displayName ?? parseFederatedUsername(m.username ?? '').baseName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible header name for a DM channel.
|
||||
*
|
||||
* - 1-on-1 DM → the other member's display/base name (or `'Direct Message'`)
|
||||
* - Group with `dm.name` set → that name verbatim
|
||||
* - Group without a name → comma-joined member names (excluding self)
|
||||
*
|
||||
* Single source of truth: prior to this, the desktop chat header
|
||||
* (`MainContent`) and the mobile chat header (`MobileChatScreen`) silently
|
||||
* dropped `dm.name`, so a renamed group still showed the joined-names
|
||||
* fallback in those two surfaces while every other site honored it.
|
||||
*/
|
||||
export function formatDmHeaderName(dm: DmChannel, currentUser: AuthLike): string {
|
||||
const isGroup = !!dm.ownerId;
|
||||
const others = otherMembersOf(dm, currentUser);
|
||||
|
||||
if (isGroup) {
|
||||
if (dm.name && dm.name.trim().length > 0) return dm.name;
|
||||
if (others.length === 0) return 'Group';
|
||||
return others.map(memberDisplayName).join(', ');
|
||||
}
|
||||
|
||||
const partner = others[0];
|
||||
if (!partner) return 'Direct Message';
|
||||
return memberDisplayName(partner) || 'Direct Message';
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder label for a DM message input. Callers prepend `'Message '`.
|
||||
*
|
||||
* - 1-on-1 DM → `'@<partner>'`
|
||||
* - Group with `dm.name` set → `'#<name>'`
|
||||
* - Group without a name → `'the group'`
|
||||
*
|
||||
* The unnamed-group case intentionally collapses to a generic noun: the
|
||||
* joined-names form is unreadable as a one-line placeholder once a group
|
||||
* has 4+ members ("Message #Test, Nova, youruser, Nova" runs off-screen
|
||||
* and obscures the actual call-to-action).
|
||||
*/
|
||||
export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string {
|
||||
const isGroup = !!dm.ownerId;
|
||||
|
||||
if (isGroup) {
|
||||
if (dm.name && dm.name.trim().length > 0) return `#${dm.name}`;
|
||||
return 'the group';
|
||||
}
|
||||
|
||||
const partner = otherMembersOf(dm, currentUser)[0];
|
||||
if (!partner) return '@unknown';
|
||||
return `@${memberDisplayName(partner) || 'unknown'}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user