feat(client): chat timeline renders name_changed + icon_changed system messages
This commit is contained in:
@@ -17,8 +17,8 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||||
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||||
import type { MessageWithUser, SpaceInviteSystemPayload } from '@backspace/shared';
|
import type { MessageWithUser } from '@backspace/shared';
|
||||||
import { SpaceInviteCard } from './SpaceInviteCard';
|
import { SystemMessage } from './SystemMessage';
|
||||||
|
|
||||||
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
||||||
const EMPTY_PENDING_BUBBLES: PendingBubble[] = [];
|
const EMPTY_PENDING_BUBBLES: PendingBubble[] = [];
|
||||||
@@ -203,6 +203,11 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY);
|
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
|
// Pending bubble interleaving — synthetic MessageWithUser-shaped objects
|
||||||
// representing optimistic sends. `Message.tsx` (Task 19) branches on the
|
// representing optimistic sends. `Message.tsx` (Task 19) branches on the
|
||||||
// `__pending` sentinel to render upload progress instead of confirmed state.
|
// `__pending` sentinel to render upload progress instead of confirmed state.
|
||||||
@@ -691,7 +696,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{msg.type === 'system' ? (
|
{msg.type === 'system' ? (
|
||||||
<SystemMessage message={msg} />
|
<SystemMessage message={msg} dm={currentDm ?? null} />
|
||||||
) : (
|
) : (
|
||||||
<Message
|
<Message
|
||||||
message={msg}
|
message={msg}
|
||||||
@@ -747,55 +752,6 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemMessage({ message }: { message: MessageWithUser }) {
|
|
||||||
let data: Record<string, unknown> = {};
|
|
||||||
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through */ }
|
|
||||||
|
|
||||||
const actorName = message.user?.displayName ?? message.user?.username ?? 'Someone';
|
|
||||||
|
|
||||||
if (data.event === 'space_invite') {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-1">
|
|
||||||
<SpaceInviteCard payload={data as unknown as SpaceInviteSystemPayload} senderName={actorName} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="flex items-center justify-center py-1 px-4 select-none">
|
|
||||||
<span className="text-xs text-txt-tertiary">
|
|
||||||
<span className="mr-1.5">{icon}</span>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WelcomeHeader({ channelId }: { channelId: string }) {
|
function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||||
const authUser = useAuthStore((s) => s.user);
|
const authUser = useAuthStore((s) => s.user);
|
||||||
|
|||||||
@@ -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<DmChannel, 'members'> = { members: [actor] };
|
||||||
|
|
||||||
|
function renderSM(message: MessageWithUser, dmArg: Pick<DmChannel, 'members'> | null) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SystemMessage message={message} dm={dmArg} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DmChannel, 'members'> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'members'> | 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<string, unknown> = {};
|
||||||
|
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
|
||||||
|
|
||||||
|
const actorName = resolveActorName(message, dm);
|
||||||
|
|
||||||
|
if (data.event === 'space_invite') {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-1">
|
||||||
|
<SpaceInviteCard payload={data as unknown as SpaceInviteSystemPayload} senderName={actorName} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex items-center justify-center py-1 px-4 select-none">
|
||||||
|
<span className="text-xs text-txt-tertiary">
|
||||||
|
<span className="mr-1.5">{icon}</span>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ vi.mock('../utils/crossStoreResolvers', () => ({
|
|||||||
resolveUserIdFromInstances: vi.fn(),
|
resolveUserIdFromInstances: vi.fn(),
|
||||||
getCachedUserIdForOrigin: vi.fn(),
|
getCachedUserIdForOrigin: vi.fn(),
|
||||||
clearMyUserIdCache: vi.fn(),
|
clearMyUserIdCache: vi.fn(),
|
||||||
|
setOwnerInstanceForDmResolver: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import after mocks so we get the mocked versions
|
// Import after mocks so we get the mocked versions
|
||||||
|
|||||||
Reference in New Issue
Block a user