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
@@ -19,6 +19,15 @@ import { useVisualViewportInset } from '../../hooks/useVisualViewportInset';
interface MessageInputProps {
channelId: string;
channelName: string;
/**
* Optional override for the textarea placeholder. When omitted the
* placeholder is derived from `channelName` (`'Message @user'` for DMs,
* `'Message #channel'` otherwise). DM call sites pass the resolved
* placeholder directly so they can collapse the unreadable joined-names
* form ("Message #Alice, Bob, Charlie, Dave") to "Message the group"
* when the group has no `dm.name` set.
*/
placeholder?: string;
}
interface MentionState {
@@ -34,7 +43,7 @@ function makeFileHandleKey(): string {
return `up-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
export function MessageInput({ channelId, channelName, placeholder }: MessageInputProps) {
// Composer state lives in composerStore (per-channel, persisted)
const composerState = useComposerStore((s) => s.states.get(channelId)) ?? {
draftText: '',
@@ -889,7 +898,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
/>
@@ -18,6 +18,7 @@ import { AvatarStack } from '../ui/AvatarStack';
import { useUIStore } from '../../stores/uiStore';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, parseFederatedUsername } from '../../utils/identity';
import { formatDmHeaderName } from '../../utils/dmFormatters';
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
import type { MessageWithUser } from '@backspace/shared';
import { SystemMessage } from './SystemMessage';
@@ -771,9 +772,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
const isGroupDm = !!dm.ownerId;
if (isGroupDm) {
const groupName = dm.name ?? otherMembers
.map(m => m.displayName ?? (m.username?.includes('@') ? m.username.split('@')[0] : m.username))
.join(', ');
const groupName = formatDmHeaderName(dm, authUser);
const ownerMember = dm.members.find(m => m.id === dm.ownerId);
const ownerName = ownerMember?.displayName ?? ownerMember?.username ?? 'Unknown';
const hasFederated = dm.members.some(m => m.homeInstance);