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:
Jannis Braun
2026-05-10 23:41:11 +02:00
parent 87ecf0f4f3
commit 7351b3d90d
9 changed files with 269 additions and 26 deletions
@@ -4,7 +4,7 @@ import { AvatarStack } from '../ui/AvatarStack';
import { Tooltip } from '../ui/Tooltip';
import { parseFederatedUsername, isSelf, isFederationGlobeApplicable } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { formatDmTimestamp, formatDmSidebarPreview } from '../../utils/dmFormatters';
import { formatDmTimestamp, formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters';
import { getRejectedPeerOrigins, getAwaitingApprovalPeerOrigins } from '../../hooks/useWebSocket';
function isMemberUnreachable(homeInstance: string | null | undefined): boolean {
@@ -44,10 +44,11 @@ export function DmListItem({ dm, isActive, isUnread, user, onSelect, onClose, on
const firstOther = rawFirstOther ? firstOtherCanonical : null;
const { baseName } = parseFederatedUsername(firstOther?.username ?? '');
// Groups → `formatDmHeaderName` (honors `dm.name`, falls back to joined
// names — same path used by the chat header, welcome hero, and mobile).
// 1-on-1 keeps the canonical-view name so replicated aliases stay correct.
const displayName = isGroup
? (dm.name ?? (otherMembers.length > 0
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
: 'Empty Group'))
? formatDmHeaderName(dm, user)
: firstOther?.displayName ?? baseName;
// Group globe: at least one member is federated → render once with comma-joined tooltip.
@@ -18,6 +18,7 @@ import { wsSend } from '../../hooks/useWebSocket';
import { MemberListToggleButton } from './MemberListToggleButton';
import { TransferIndicator } from './TransferIndicator';
import { isSelf, parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
import { formatDmHeaderName, formatDmInputLabel } from '../../utils/dmFormatters';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import type { User } from '@backspace/shared';
import { Tooltip } from '../ui/Tooltip';
@@ -100,9 +101,23 @@ export function MainContent() {
// conditional so the hook is called unconditionally).
const firstOther = _rawFirstOther ? _canonicalFirstOther : (otherMembers[0] ?? null);
const { baseName: firstBaseName } = parseFederatedUsername(firstOther?.username ?? '');
const dmName = isGroupDm
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
: firstOther?.displayName ?? (firstBaseName || 'Direct Message');
// Group DMs route through `formatDmHeaderName` (honors `dm.name`, falls
// back to joined member names); 1-on-1 DMs keep the canonical-view path
// so replicated aliases still surface the home-instance display name.
const dmName = isGroupDm && dmChannel
? formatDmHeaderName(dmChannel, authUser)
: (firstOther?.displayName ?? (firstBaseName || 'Direct Message'));
// Message-input placeholder — groups use `formatDmInputLabel` which
// collapses unnamed groups to "the group" so the textarea doesn't render
// "Message #Alice, Bob, Charlie, Dave". 1-on-1 reuses the canonical
// `dmName` so the placeholder stays aligned with the header (the utility
// resolves the raw partner; on replicated aliases the canonical view
// can disagree).
const dmInputPlaceholder = isGroupDm && dmChannel
? `Message ${formatDmInputLabel(dmChannel, authUser)}`
: dmChannel
? `Message @${dmName}`
: undefined;
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
@@ -289,7 +304,7 @@ export function MainContent() {
</div>
</div>
<MessageList channelId={currentChannelId} jumpToMessageId={jumpToMessageId} onJumpComplete={() => setJumpToMessageId(null)} />
<MessageInput channelId={currentChannelId} channelName={`@${dmName}`} />
<MessageInput channelId={currentChannelId} channelName={`@${dmName}`} placeholder={dmInputPlaceholder} />
<SearchPopover
open={searchOpen}
onClose={() => setSearchOpen(false)}
@@ -7,6 +7,7 @@ import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TransferIndicator } from './TransferIndicator';
import { parseFederatedUsername } from '../../utils/identity';
import { formatDmHeaderName, formatDmInputLabel } from '../../utils/dmFormatters';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import type { User } from '@backspace/shared';
@@ -47,18 +48,25 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
const rawMainOther = !isGroup ? otherMembers[0] : undefined;
const canonicalMainOther = useCanonicalUserView((rawMainOther as unknown as User) ?? FALLBACK_USER);
// Resolve channel/DM name
// Resolve channel/DM name. Group DMs route through `formatDmHeaderName` so
// a renamed group shows `dm.name` (previously this surface silently dropped
// it and always rendered the joined-names fallback). 1-on-1 DMs keep the
// canonical-view lookup so replicated aliases still surface the home
// account's displayName.
let channelName = 'Channel';
let inputPlaceholder: string | undefined;
if (isDm && dm) {
if (isGroup) {
channelName = otherMembers
.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName)
.join(', ');
channelName = formatDmHeaderName(dm, authUser);
inputPlaceholder = `Message ${formatDmInputLabel(dm, authUser)}`;
} else if (rawMainOther) {
channelName =
canonicalMainOther.displayName ??
parseFederatedUsername(canonicalMainOther.username).baseName ??
'Direct Message';
// Use the canonical `channelName` directly so header + placeholder stay
// aligned even when the raw partner and canonical view disagree.
inputPlaceholder = `Message @${channelName}`;
} else {
channelName = 'Direct Message';
}
@@ -119,7 +127,7 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
`absolute bottom-full` to the bubble), so we don't render it here. */}
<div className="relative flex-1 min-h-0 flex flex-col overflow-hidden">
{channelId && <MessageList channelId={channelId} />}
{channelId && <MessageInput channelId={channelId} channelName={channelName} />}
{channelId && <MessageInput channelId={channelId} channelName={channelName} placeholder={inputPlaceholder} />}
</div>
</div>
);
@@ -12,7 +12,7 @@ import { resolveAssetUrl } from '../../utils/assetUrls';
import { useNavigate } from 'react-router-dom';
import { parseFederatedUsername, isFederationGlobeApplicable, isSelf } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { formatDmSidebarPreview } from '../../utils/dmFormatters';
import { formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters';
import type { DmChannel, User } from '@backspace/shared';
import type { TaggedFriend } from '../../stores/socialStore';
@@ -88,13 +88,11 @@ function MobileDmRow({
const canonicalMainUser = useCanonicalUserView(rawMainUser ?? FALLBACK_USER);
const mainUser = rawMainUser ? canonicalMainUser : null;
// Group DMs use `dm.name` when set, else fall back to a comma-joined member
// list (matches `MobileChatScreen` + `DmListItem`). 1:1 DMs use the
// canonical view of the single other member.
// Group DMs → `formatDmHeaderName` (single source of truth shared with
// `MobileChatScreen`, `MainContent`, `DmListItem`, welcome hero). 1:1 DMs
// keep the canonical view of the single other member.
const name = isGroup
? (dm.name && dm.name.length > 0
? dm.name
: otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', '))
? formatDmHeaderName(dm, authUser ?? null)
: mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || 'Unknown');
// Show a single federation globe next to the group name when any non-self