import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared'; import { SpaceInviteCard } from './SpaceInviteCard'; import { useT } from '../../i18n'; 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 | undefined, unknown: string, someone: string, ): 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) { const t = useT(); let data: Record = {}; try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ } const actorName = resolveActorName(message, dm, t('system.unknown'), t('system.someone')); if (data.event === 'space_invite') { return (
); } // Inline-text events. let text = ''; let icon = ''; switch (data.event) { case 'member_added': icon = '→'; // → text = t('system.memberAdded', { actor: actorName, target: String(data.targetDisplayName) }); break; case 'member_removed': if (data.reason === 'leave') { icon = '←'; // ← text = t('system.memberLeft', { target: String(data.targetDisplayName) }); } else { icon = '←'; text = t('system.memberRemoved', { actor: actorName, target: String(data.targetDisplayName) }); } break; case 'owner_changed': icon = '♛'; // ♛ text = t('system.ownerChanged', { owner: String(data.newOwnerDisplayName) }); 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 ? t('system.nameChanged', { actor: actorName, name: data.newName }) : t('system.nameCleared', { actor: actorName }); break; case 'icon_changed': icon = '\u{1F5BC}'; // 🖼 text = t('system.iconChanged', { actor: actorName }); break; default: text = message.content ?? ''; } return (
{icon} {text}
); }