diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index 8d4ca6f1..7da172d0 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -17,8 +17,8 @@ import { Avatar } from '../ui/Avatar'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; -import type { MessageWithUser, SpaceInviteSystemPayload } from '@backspace/shared'; -import { SpaceInviteCard } from './SpaceInviteCard'; +import type { MessageWithUser } from '@backspace/shared'; +import { SystemMessage } from './SystemMessage'; const EMPTY_MESSAGES: MessageWithUser[] = []; const EMPTY_PENDING_BUBBLES: PendingBubble[] = []; @@ -203,6 +203,11 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess const isDm = isDmChannel(channelId); const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY); + // Channel-specific DM record (if applicable). Passed to SystemMessage so it + // can resolve actor display names from the channel roster — needed for + // events that don't embed the actor (name_changed, icon_changed, owner_changed). + const currentDm = useSpaceStore((s) => isDm ? s.dmChannels.find(d => d.id === channelId) : undefined); + // Pending bubble interleaving — synthetic MessageWithUser-shaped objects // representing optimistic sends. `Message.tsx` (Task 19) branches on the // `__pending` sentinel to render upload progress instead of confirmed state. @@ -691,7 +696,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess )} {msg.type === 'system' ? ( - + ) : ( = {}; - try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through */ } - - const actorName = message.user?.displayName ?? message.user?.username ?? 'Someone'; - - if (data.event === 'space_invite') { - return ( -
- -
- ); - } - - // Legacy inline-text events - let text = ''; - let icon = ''; - switch (data.event) { - case 'member_added': - icon = '\u2192'; // → - text = `${actorName} added ${data.targetDisplayName} to the group`; - break; - case 'member_removed': - if (data.reason === 'leave') { - icon = '\u2190'; // ← - text = `${data.targetDisplayName} left the group`; - } else { - icon = '\u2190'; - text = `${actorName} removed ${data.targetDisplayName} from the group`; - } - break; - case 'owner_changed': - icon = '\u265B'; // ♛ - text = `${data.newOwnerDisplayName} is now the group owner`; - break; - default: - text = message.content ?? ''; - } - - return ( -
- - {icon} - {text} - -
- ); -} - function WelcomeHeader({ channelId }: { channelId: string }) { const dmChannels = useSpaceStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); diff --git a/packages/web/src/components/chat/SystemMessage.test.tsx b/packages/web/src/components/chat/SystemMessage.test.tsx new file mode 100644 index 00000000..18283be7 --- /dev/null +++ b/packages/web/src/components/chat/SystemMessage.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SystemMessage } from './SystemMessage'; +import type { DmChannel, MessageWithUser, User } from '@backspace/shared'; + +// SpaceInviteCard is unrelated to the cases under test but is imported by +// SystemMessage; stub its store hooks so the import graph resolves cleanly. +vi.mock('../../stores/spaceStore', () => ({ + useSpaceStore: (selector: (s: unknown) => unknown) => + selector({ joinByCode: vi.fn() }), + getApiForOrigin: vi.fn(), +})); +vi.mock('../../api/client', () => ({ + api: {}, + createApiClient: vi.fn(), +})); + +const actor: User = { + id: 'U1', + username: 'jannis', + displayName: 'Jannis', + avatar: null, + banner: null, + accentColor: null, + avatarColor: 'mint', + bio: null, + status: 'online', + customStatus: null, + isAdmin: false, + createdAt: 0, + homeUserId: null, + homeInstance: null, + replicatedInstances: [], +}; + +function buildMessage(content: object, userId = 'U1'): MessageWithUser { + return { + id: 'M1', + channelId: '', + userId, + user: actor, + content: JSON.stringify(content), + type: 'system', + createdAt: 1, + editedAt: null, + replyToId: null, + replyTo: null, + attachments: [], + embeds: [], + reactions: [], + mentions: [], + everyoneMentioned: false, + pinnedAt: null, + } as unknown as MessageWithUser; +} + +const dm: Pick = { members: [actor] }; + +function renderSM(message: MessageWithUser, dmArg: Pick | null) { + return render( + + + , + ); +} + +describe('SystemMessage — name_changed', () => { + it('newName="Cool Group" with resolvable actor → "✎ Jannis renamed the group to \\"Cool Group\\""', () => { + const msg = buildMessage({ event: 'name_changed', oldName: null, newName: 'Cool Group' }); + renderSM(msg, dm); + expect(screen.getByText('✎')).toBeDefined(); + expect(screen.getByText(/Jannis renamed the group to "Cool Group"/)).toBeDefined(); + }); + + it('newName=null (cleared) with resolvable actor → "✎ Jannis cleared the group name"', () => { + const msg = buildMessage({ event: 'name_changed', oldName: 'Old', newName: null }); + renderSM(msg, dm); + expect(screen.getByText('✎')).toBeDefined(); + expect(screen.getByText(/Jannis cleared the group name/)).toBeDefined(); + }); + + it('unresolvable actor (member missing from roster) → "✎ Unknown renamed …"', () => { + const msg = buildMessage({ event: 'name_changed', oldName: null, newName: 'X' }, 'GHOST'); + renderSM(msg, dm); // dm.members has only U1, not GHOST + expect(screen.getByText(/Unknown renamed the group to "X"/)).toBeDefined(); + }); +}); + +describe('SystemMessage — icon_changed', () => { + it('resolvable actor → "🖼 Jannis updated the group icon"', () => { + const msg = buildMessage({ event: 'icon_changed' }); + renderSM(msg, dm); + // The 🖼 character is U+1F5BC (FRAME WITH PICTURE), not 🖼️ (with VS-16). + expect(screen.getByText('\u{1F5BC}')).toBeDefined(); + expect(screen.getByText(/Jannis updated the group icon/)).toBeDefined(); + }); +}); diff --git a/packages/web/src/components/chat/SystemMessage.tsx b/packages/web/src/components/chat/SystemMessage.tsx new file mode 100644 index 00000000..44ceb63f --- /dev/null +++ b/packages/web/src/components/chat/SystemMessage.tsx @@ -0,0 +1,93 @@ +import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared'; +import { SpaceInviteCard } from './SpaceInviteCard'; + +interface SystemMessageProps { + message: MessageWithUser; + /** + * The enclosing DM channel, if the message belongs to one. Used to resolve + * the actor's display name from the channel roster (`dm.members`) for events + * that don't carry it in the payload (e.g. `name_changed`, `icon_changed`, + * `owner_changed`). When omitted (e.g. server channels), the renderer falls + * back to the embedded `message.user`. + */ + dm?: Pick | null; +} + +function resolveActorName(message: MessageWithUser, dm?: Pick | null): string { + if (dm) { + const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined; + if (fromRoster) { + return fromRoster.displayName ?? fromRoster.username ?? 'Unknown'; + } + return 'Unknown'; + } + return message.user?.displayName ?? message.user?.username ?? 'Someone'; +} + +/** + * Inline timeline renderer for system DM messages. Mirrors the sidebar's + * `formatSystemPreview` semantics but with icons + a slightly fuller phrasing + * (e.g. surfacing the new name in `name_changed`). + * + * Exported so unit tests can render it directly without mounting MessageList. + */ +export function SystemMessage({ message, dm }: SystemMessageProps) { + let data: Record = {}; + try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ } + + const actorName = resolveActorName(message, dm); + + if (data.event === 'space_invite') { + return ( +
+ +
+ ); + } + + // Inline-text events. + let text = ''; + let icon = ''; + switch (data.event) { + case 'member_added': + icon = '→'; // → + text = `${actorName} added ${data.targetDisplayName} to the group`; + break; + case 'member_removed': + if (data.reason === 'leave') { + icon = '←'; // ← + text = `${data.targetDisplayName} left the group`; + } else { + icon = '←'; + text = `${actorName} removed ${data.targetDisplayName} from the group`; + } + break; + case 'owner_changed': + icon = '♛'; // ♛ + text = `${data.newOwnerDisplayName} is now the group owner`; + break; + case 'name_changed': + icon = '✎'; // ✎ + // newName === null is a meaningful "cleared" state — distinct from a + // missing field — so we test for a non-empty string explicitly. + text = typeof data.newName === 'string' && data.newName.length > 0 + ? `${actorName} renamed the group to "${data.newName}"` + : `${actorName} cleared the group name`; + break; + case 'icon_changed': + icon = '\u{1F5BC}'; // 🖼 + text = `${actorName} updated the group icon`; + break; + default: + text = message.content ?? ''; + } + + return ( +
+ + {icon} + {text} + +
+ ); +} diff --git a/packages/web/src/stores/spaceStore.joinByCode.test.ts b/packages/web/src/stores/spaceStore.joinByCode.test.ts index 4ac00a43..4f6939ba 100644 --- a/packages/web/src/stores/spaceStore.joinByCode.test.ts +++ b/packages/web/src/stores/spaceStore.joinByCode.test.ts @@ -49,6 +49,7 @@ vi.mock('../utils/crossStoreResolvers', () => ({ resolveUserIdFromInstances: vi.fn(), getCachedUserIdForOrigin: vi.fn(), clearMyUserIdCache: vi.fn(), + setOwnerInstanceForDmResolver: vi.fn(), })); // Import after mocks so we get the mocked versions