Files
backspace/packages/web/src/utils/dmFormatters.ts
T

317 lines
12 KiB
TypeScript

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<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`;
}
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<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 ─────────────────────────────────────────────────
/**
* 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 → `'@<partner>'`
* - Group with `dm.name` set → `'#<name>'`
* - 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'}`;
}