import type { DmChannel, DmMessageWithUser, DmLastMessagePreview, User, SpaceInviteSystemPayload } from '@backspace/shared'; import { parseFederatedUsername, isSelf } from './identity'; // ─── DM Preview Formatting ──────────────────────────────────────────────────── /** * A loose attachment shape that covers both the ready-payload preview * (uses `type` + `filename`) and the full Attachment type from shared types * (uses `mimetype` + `originalName`). At least one of each pair must be present. */ interface PreviewAttachment { type?: string; mimetype?: string; filename?: string; originalName?: string; } interface PreviewMessage { content: string | null | undefined; attachments?: PreviewAttachment[]; } function resolveAttachmentType(a: PreviewAttachment): string { return a.type ?? a.mimetype ?? ''; } function resolveAttachmentName(a: PreviewAttachment): string { return a.filename ?? a.originalName ?? ''; } function getAttachmentIcon(mimeType: string): string { if (mimeType.startsWith('image/')) return '📷'; if (mimeType.startsWith('video/')) return '🎬'; if (mimeType.startsWith('audio/')) return '🎵'; return '📎'; } function getAttachmentLabel(mimeType: string, name: string): string { if (mimeType.startsWith('image/')) return '📷 Image'; if (mimeType.startsWith('video/')) return '🎬 Video'; if (mimeType.startsWith('audio/')) return '🎵 Audio'; return `📎 ${name}`; } /** * Determines a single icon representing a set of attachments. * If all attachments share the same category icon, returns that icon. * If mixed, falls back to 📎. */ function getUnifiedAttachmentIcon(attachments: PreviewAttachment[]): string { const icons = new Set(attachments.map(a => getAttachmentIcon(resolveAttachmentType(a)))); return icons.size === 1 ? [...icons][0]! : '📎'; } /** * Format a DM lastMessage's user-authored content into a sidebar preview string. * Returns null if there is nothing displayable. * * Handles: * - Text only → returns text content * - 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; const { content, attachments } = lastMessage; const hasText = content != null && content.length > 0; const hasAttachments = attachments != null && attachments.length > 0; if (!hasText && !hasAttachments) return null; if (hasText && !hasAttachments) { return content!; } if (!hasText && hasAttachments) { if (attachments!.length === 1) { const a = attachments![0]!; return getAttachmentLabel(resolveAttachmentType(a), resolveAttachmentName(a)); } return `📎 ${attachments!.length} files`; } // Both text and attachments present const icon = getUnifiedAttachmentIcon(attachments!); return `${content} ${icon}`; } // ─── System Message Preview Formatting ─────────────────────────────────────── interface SystemEventPayload { event?: unknown; targetUserId?: unknown; targetDisplayName?: unknown; newOwnerId?: unknown; newOwnerDisplayName?: unknown; reason?: unknown; // name_changed payload oldName?: unknown; newName?: 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).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`; } case 'name_changed': { // newName === null is a meaningful "cleared" state — distinct from a // missing field — so we check for a non-empty string explicitly. return asString(data.newName) ? `${actorName} renamed the group` : `${actorName} cleared the group name`; } case 'icon_changed': { return `${actorName} updated the group icon`; } 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, 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 ───────────────────────────────────────────────── /** * Smart timestamp for DM sidebar items. * Today → time ("4:32 PM"), Yesterday → "Yesterday", * This year → "Mar 31", Older → "Dec 14, 2025" */ export function formatDmTimestamp(createdAt: number): string { const now = new Date(); const date = new Date(createdAt); // Build "start of today" and "start of yesterday" in local time const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startOfYesterday = new Date(startOfToday.getTime() - 86400000); if (date >= startOfToday) { // Today — show time return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); } if (date >= startOfYesterday) { return 'Yesterday'; } if (date.getFullYear() === now.getFullYear()) { // This year — "Mar 31" return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } // Previous year — "Dec 14, 2025" return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } // ─── DM Display Names ───────────────────────────────────────────────────────── type AuthLike = { id: string; username: string; homeInstance?: string | null } | null; /** Other-side member resolution, identical to every other DM display path. */ function otherMembersOf(dm: DmChannel, currentUser: AuthLike): User[] { return dm.members.filter(m => !isSelf(m, currentUser)); } /** Member's visible name — display name if set, else the parsed base of the username. */ function memberDisplayName(m: User): string { return m.displayName ?? parseFederatedUsername(m.username ?? '').baseName ?? ''; } /** * Visible header name for a DM channel. * * - 1-on-1 DM → the other member's display/base name (or `'Direct Message'`) * - Group with `dm.name` set → that name verbatim * - Group without a name → comma-joined member names (excluding self) * * Single source of truth: prior to this, the desktop chat header * (`MainContent`) and the mobile chat header (`MobileChatScreen`) silently * dropped `dm.name`, so a renamed group still showed the joined-names * fallback in those two surfaces while every other site honored it. */ export function formatDmHeaderName(dm: DmChannel, currentUser: AuthLike): string { const isGroup = !!dm.ownerId; const others = otherMembersOf(dm, currentUser); if (isGroup) { if (dm.name && dm.name.trim().length > 0) return dm.name; if (others.length === 0) return 'Group'; return others.map(memberDisplayName).join(', '); } const partner = others[0]; if (!partner) return 'Direct Message'; return memberDisplayName(partner) || 'Direct Message'; } /** * Placeholder label for a DM message input. Callers prepend `'Message '`. * * - 1-on-1 DM → `'@'` * - Group with `dm.name` set → `'#'` * - Group without a name → `'the group'` * * The unnamed-group case intentionally collapses to a generic noun: the * joined-names form is unreadable as a one-line placeholder once a group * has 4+ members ("Message #Test, Nova, erin, Nova" runs off-screen * and obscures the actual call-to-action). */ export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string { const isGroup = !!dm.ownerId; if (isGroup) { if (dm.name && dm.name.trim().length > 0) return `#${dm.name}`; return 'the group'; } const partner = otherMembersOf(dm, currentUser)[0]; if (!partner) return '@unknown'; return `@${memberDisplayName(partner) || 'unknown'}`; }