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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { formatDmTimestamp, formatDmPreview, formatDmSidebarPreview } from './dmFormatters';
|
||||
import { formatDmTimestamp, formatDmPreview, formatDmSidebarPreview, formatDmHeaderName, formatDmInputLabel } from './dmFormatters';
|
||||
import type { DmChannel, DmLastMessagePreview, User } from '@backspace/shared';
|
||||
|
||||
/** Build a local-time Date: new Date(year, month-1, day, hour, minute) as a timestamp. */
|
||||
@@ -241,6 +241,148 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDmHeaderName / formatDmInputLabel ──────────────────────────────────
|
||||
|
||||
function makeMember(id: string, fields: Partial<User> = {}): User {
|
||||
return {
|
||||
id,
|
||||
username: id.toLowerCase(),
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
homeInstance: null,
|
||||
status: 'offline',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: 0,
|
||||
replicatedInstances: [],
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupDmFull(args: { name: string | null; otherMembers: User[]; ownerId?: string }): DmChannel {
|
||||
return {
|
||||
id: 'dm-1',
|
||||
ownerId: args.ownerId ?? 'OWNER',
|
||||
name: args.name,
|
||||
icon: null,
|
||||
members: [makeMember('SELF', { username: 'self' }), ...args.otherMembers],
|
||||
lastMessage: null,
|
||||
metadataUpdatedAt: 0,
|
||||
} as DmChannel;
|
||||
}
|
||||
|
||||
function make1on1Dm(other: User): DmChannel {
|
||||
return {
|
||||
id: 'dm-1',
|
||||
ownerId: null,
|
||||
name: null,
|
||||
icon: null,
|
||||
members: [makeMember('SELF', { username: 'self' }), other],
|
||||
lastMessage: null,
|
||||
metadataUpdatedAt: 0,
|
||||
} as DmChannel;
|
||||
}
|
||||
|
||||
const SELF = { id: 'SELF', username: 'self' };
|
||||
|
||||
describe('formatDmHeaderName', () => {
|
||||
it('group with `dm.name` set → returns dm.name verbatim', () => {
|
||||
const dm = makeGroupDmFull({
|
||||
name: 'Cool Group',
|
||||
otherMembers: [makeMember('A', { displayName: 'Alice' })],
|
||||
});
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Cool Group');
|
||||
});
|
||||
|
||||
it('group with whitespace-only dm.name → falls back to joined names', () => {
|
||||
const dm = makeGroupDmFull({
|
||||
name: ' ',
|
||||
otherMembers: [makeMember('A', { displayName: 'Alice' }), makeMember('B', { displayName: 'Bob' })],
|
||||
});
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Alice, Bob');
|
||||
});
|
||||
|
||||
it('group without a name → comma-joined member display names (self excluded)', () => {
|
||||
const dm = makeGroupDmFull({
|
||||
name: null,
|
||||
otherMembers: [
|
||||
makeMember('A', { displayName: 'Alice' }),
|
||||
makeMember('B', { displayName: 'Bob' }),
|
||||
makeMember('C', { displayName: 'Charlie' }),
|
||||
],
|
||||
});
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Alice, Bob, Charlie');
|
||||
});
|
||||
|
||||
it('group with members lacking displayName → falls back to parseFederatedUsername base', () => {
|
||||
const dm = makeGroupDmFull({
|
||||
name: null,
|
||||
otherMembers: [makeMember('A', { username: 'alice@nova.ddns.net' })],
|
||||
});
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('alice');
|
||||
});
|
||||
|
||||
it('group with only self → returns "Group" placeholder', () => {
|
||||
const dm = makeGroupDmFull({ name: null, otherMembers: [] });
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Group');
|
||||
});
|
||||
|
||||
it('1-on-1 → partner display name', () => {
|
||||
const dm = make1on1Dm(makeMember('A', { displayName: 'Alice' }));
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Alice');
|
||||
});
|
||||
|
||||
it('1-on-1 without displayName → username base', () => {
|
||||
const dm = make1on1Dm(makeMember('A', { username: 'alice@nova.ddns.net' }));
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('alice');
|
||||
});
|
||||
|
||||
it('1-on-1 with no resolvable partner → "Direct Message"', () => {
|
||||
const dm: DmChannel = {
|
||||
id: 'dm-1', ownerId: null, name: null, icon: null,
|
||||
members: [makeMember('SELF', { username: 'self' })],
|
||||
lastMessage: null, metadataUpdatedAt: 0,
|
||||
} as DmChannel;
|
||||
expect(formatDmHeaderName(dm, SELF)).toBe('Direct Message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDmInputLabel', () => {
|
||||
it('group with `dm.name` set → "#<name>"', () => {
|
||||
const dm = makeGroupDmFull({ name: 'Cool Group', otherMembers: [makeMember('A')] });
|
||||
expect(formatDmInputLabel(dm, SELF)).toBe('#Cool Group');
|
||||
});
|
||||
|
||||
it('group without a name → "the group" (collapses joined-names form)', () => {
|
||||
// Regression: previously the placeholder showed
|
||||
// "Message #Alice, Bob, Charlie, Dave" which overflows the input.
|
||||
const dm = makeGroupDmFull({
|
||||
name: null,
|
||||
otherMembers: [
|
||||
makeMember('A', { displayName: 'Alice' }),
|
||||
makeMember('B', { displayName: 'Bob' }),
|
||||
makeMember('C', { displayName: 'Charlie' }),
|
||||
makeMember('D', { displayName: 'Dave' }),
|
||||
],
|
||||
});
|
||||
expect(formatDmInputLabel(dm, SELF)).toBe('the group');
|
||||
});
|
||||
|
||||
it('group with whitespace-only dm.name → "the group"', () => {
|
||||
const dm = makeGroupDmFull({ name: ' ', otherMembers: [makeMember('A')] });
|
||||
expect(formatDmInputLabel(dm, SELF)).toBe('the group');
|
||||
});
|
||||
|
||||
it('1-on-1 → "@<partner>"', () => {
|
||||
const dm = make1on1Dm(makeMember('A', { displayName: 'Alice' }));
|
||||
expect(formatDmInputLabel(dm, SELF)).toBe('@Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDmSidebarPreview — icon_changed system message', () => {
|
||||
it('happy path → "<actor> updated the group icon"', () => {
|
||||
const dm = makeGroupDm({
|
||||
|
||||
Reference in New Issue
Block a user