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:
@@ -1329,7 +1329,12 @@ function buildReadyPayload(userId: string): {
|
||||
id: dmChannel.id,
|
||||
federatedId: dmChannel.federatedId ?? null,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
ownerHomeUserId: dmChannel.ownerHomeUserId ?? null,
|
||||
ownerHomeInstance: dmChannel.ownerHomeInstance ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
name: dmChannel.name ?? null,
|
||||
icon: dmChannel.icon ?? null,
|
||||
metadataUpdatedAt: dmChannel.metadataUpdatedAt ?? 0,
|
||||
members,
|
||||
lastMessage: last ? {
|
||||
id: last.id,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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