fix(dm): render system messages in sidebar preview instead of raw JSON

DmLastMessagePreview lacked a `type` field, so the sidebar rendered
`lastMessage.content` verbatim — surfacing JSON like
`{"event":"space_invite",...}` for space invites and member-add events.

Adds `type` to the preview payload (populated server-side from
`dm_messages.type`) and routes all sidebar call sites through a single
`formatDmSidebarPreview` helper that renders human-readable text for
each system event and skips the group `Sender:` prefix on system rows.
This commit is contained in:
Jannis Braun
2026-04-29 23:13:09 +02:00
parent 9aa97f4552
commit 1ed70a90b1
7 changed files with 164 additions and 23 deletions
+119 -1
View File
@@ -1,3 +1,6 @@
import type { DmChannel, DmMessageWithUser, DmLastMessagePreview, User, SpaceInviteSystemPayload } from '@backspace/shared';
import { parseFederatedUsername, isSelf } from './identity';
// ─── DM Preview Formatting ────────────────────────────────────────────────────
/**
@@ -50,7 +53,7 @@ function getUnifiedAttachmentIcon(attachments: PreviewAttachment[]): string {
}
/**
* Format a DM lastMessage into a sidebar preview string.
* Format a DM lastMessage's user-authored content into a sidebar preview string.
* Returns null if there is nothing displayable.
*
* Handles:
@@ -58,6 +61,10 @@ function getUnifiedAttachmentIcon(attachments: PreviewAttachment[]): string {
* - Attachment only (single) → "📷 Image", "🎬 Video", "🎵 Audio", "📎 filename.ext"
* - Attachment only (multiple) → "📎 N files"
* - Text + attachments → "text 📷" (appends unified icon)
*
* NOTE: This helper does NOT understand system messages — for those, use
* `formatDmSidebarPreview` which inspects `type` and routes to a system-message
* renderer. Calling this directly on a system message would surface raw JSON.
*/
export function formatDmPreview(lastMessage: PreviewMessage | null | undefined): string | null {
if (!lastMessage) return null;
@@ -85,6 +92,117 @@ export function formatDmPreview(lastMessage: PreviewMessage | null | undefined):
return `${content} ${icon}`;
}
// ─── System Message Preview Formatting ───────────────────────────────────────
interface SystemEventPayload {
event?: unknown;
targetUserId?: unknown;
targetDisplayName?: unknown;
newOwnerId?: unknown;
newOwnerDisplayName?: unknown;
reason?: unknown;
// space_invite payload
snapshot?: { spaceName?: unknown };
}
function asString(v: unknown): string | null {
return typeof v === 'string' && v.length > 0 ? v : null;
}
function resolveDisplayName(user: User | null | undefined): string {
if (!user) return 'Unknown';
if (user.displayName) return user.displayName;
return parseFederatedUsername(user.username ?? '').baseName || 'Unknown';
}
/**
* Format a system DM message (member_added, member_removed, owner_changed,
* space_invite) into a human-readable sidebar preview. Falls back to a generic
* label for unknown event shapes so we never leak raw JSON to the sidebar.
*/
function formatSystemPreview(content: string | null, actor: User | null | undefined): string {
let data: SystemEventPayload = {};
if (content) {
try { data = JSON.parse(content) as SystemEventPayload; } catch { /* malformed → generic fallback */ }
}
const event = asString(data.event);
const actorName = resolveDisplayName(actor);
switch (event) {
case 'space_invite': {
const spaceName = asString((data as Partial<SpaceInviteSystemPayload>).snapshot?.spaceName);
return spaceName
? `📨 Sent invite to ${spaceName}`
: '📨 Sent a space invite';
}
case 'member_added': {
const target = asString(data.targetDisplayName) ?? 'someone';
return `${actorName} added ${target}`;
}
case 'member_removed': {
const target = asString(data.targetDisplayName) ?? 'someone';
const reason = asString(data.reason);
if (reason === 'leave') return `${target} left the group`;
return `${actorName} removed ${target}`;
}
case 'owner_changed': {
const newOwner = asString(data.newOwnerDisplayName) ?? 'A member';
return `${newOwner} is now the group owner`;
}
default:
return 'System message';
}
}
// ─── Unified Sidebar Preview ─────────────────────────────────────────────────
type LastMessageLike = DmLastMessagePreview | DmMessageWithUser;
function isSystemMessage(m: LastMessageLike): boolean {
return m.type === 'system';
}
/**
* Produce the full sidebar preview line for a DM channel. Handles:
* - User messages → text/attachment formatting (with `Sender: ` prefix in groups
* when the author is not the current user)
* - System messages → human-readable rendering with no sender prefix (the system
* text already incorporates the actor where appropriate)
* - Empty state → null (caller decides the fallback, e.g. "N Members")
*/
export function formatDmSidebarPreview(
dm: Pick<DmChannel, 'lastMessage' | 'ownerId' | 'members'>,
currentUser: { id: string; username: string } | null,
): string | null {
const lastMessage = dm.lastMessage ?? null;
if (!lastMessage) return null;
// Resolve the message author from the channel members. Falls back to the
// user object embedded in DmMessageWithUser if the member roster doesn't
// include them (e.g. a remote actor in federation bootstrap).
const actor: User | null = (
dm.members.find(m => m.id === lastMessage.userId)
?? ('user' in lastMessage ? lastMessage.user : null)
?? null
);
if (isSystemMessage(lastMessage)) {
return formatSystemPreview(lastMessage.content ?? null, actor);
}
const text = formatDmPreview(lastMessage);
if (!text) return null;
const isGroup = !!dm.ownerId;
if (!isGroup) return text;
// Group user messages: prefix with sender display name unless it's the current user.
const authoredBySelf = currentUser ? isSelf({ id: lastMessage.userId, username: actor?.username ?? '', homeInstance: actor?.homeInstance ?? null }, currentUser) : false;
if (authoredBySelf) return text;
return `${resolveDisplayName(actor)}: ${text}`;
}
// ─── DM Timestamp Formatting ─────────────────────────────────────────────────
/**