From c0151996c4d3fd7838d1e4e876f9121cd1779fea Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Sun, 10 May 2026 19:47:18 +0200
Subject: [PATCH] feat(client): chat timeline renders name_changed +
icon_changed system messages
---
.../web/src/components/chat/MessageList.tsx | 60 ++----------
.../components/chat/SystemMessage.test.tsx | 98 +++++++++++++++++++
.../web/src/components/chat/SystemMessage.tsx | 93 ++++++++++++++++++
.../src/stores/spaceStore.joinByCode.test.ts | 1 +
4 files changed, 200 insertions(+), 52 deletions(-)
create mode 100644 packages/web/src/components/chat/SystemMessage.test.tsx
create mode 100644 packages/web/src/components/chat/SystemMessage.tsx
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