diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index bd51a7c7..272c50f2 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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, diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index f06ede27..49e516da 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -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} /> diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index c709fb75..ff129445 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -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); diff --git a/packages/web/src/components/layout/DmListItem.tsx b/packages/web/src/components/layout/DmListItem.tsx index 47f10818..c6e56271 100644 --- a/packages/web/src/components/layout/DmListItem.tsx +++ b/packages/web/src/components/layout/DmListItem.tsx @@ -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. diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index a44cab6e..75ec0dac 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -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() { setJumpToMessageId(null)} /> - + setSearchOpen(false)} diff --git a/packages/web/src/components/layout/MobileChatScreen.tsx b/packages/web/src/components/layout/MobileChatScreen.tsx index ce7655fa..8786c860 100644 --- a/packages/web/src/components/layout/MobileChatScreen.tsx +++ b/packages/web/src/components/layout/MobileChatScreen.tsx @@ -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. */}
{channelId && } - {channelId && } + {channelId && }
); diff --git a/packages/web/src/components/layout/MobileDmsScreen.tsx b/packages/web/src/components/layout/MobileDmsScreen.tsx index b79472f6..4006c997 100644 --- a/packages/web/src/components/layout/MobileDmsScreen.tsx +++ b/packages/web/src/components/layout/MobileDmsScreen.tsx @@ -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 diff --git a/packages/web/src/utils/dmFormatters.test.ts b/packages/web/src/utils/dmFormatters.test.ts index 3513442b..e2736bae 100644 --- a/packages/web/src/utils/dmFormatters.test.ts +++ b/packages/web/src/utils/dmFormatters.test.ts @@ -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 { + 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 → "#"', () => { + 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 → "@"', () => { + const dm = make1on1Dm(makeMember('A', { displayName: 'Alice' })); + expect(formatDmInputLabel(dm, SELF)).toBe('@Alice'); + }); +}); + describe('formatDmSidebarPreview — icon_changed system message', () => { it('happy path → " updated the group icon"', () => { const dm = makeGroupDm({ diff --git a/packages/web/src/utils/dmFormatters.ts b/packages/web/src/utils/dmFormatters.ts index f4f1000c..dfe9b752 100644 --- a/packages/web/src/utils/dmFormatters.ts +++ b/packages/web/src/utils/dmFormatters.ts @@ -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 → `'@'` + * - Group with `dm.name` set → `'#'` + * - 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'}`; +}