From b82733868b3d1a84b7ce5c59aab2dcc86e57050b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:48:28 +0200 Subject: [PATCH] feat(web): add formatDmPreview utility with attachment-aware previews Supports text-only, attachment-only (with type-specific icons), and mixed messages. PreviewAttachment accepts both type/filename (ready payload) and mimetype/originalName (Attachment type) field shapes. --- packages/web/src/utils/dmFormatters.test.ts | 122 ++++++++++++++++++++ packages/web/src/utils/dmFormatters.ts | 89 ++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/packages/web/src/utils/dmFormatters.test.ts b/packages/web/src/utils/dmFormatters.test.ts index 5e8e87b3..84633c0b 100644 --- a/packages/web/src/utils/dmFormatters.test.ts +++ b/packages/web/src/utils/dmFormatters.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { formatDmTimestamp } from './dmFormatters'; +import { formatDmPreview } from './dmFormatters'; /** Build a local-time Date: new Date(year, month-1, day, hour, minute) as a timestamp. */ function localTs(year: number, month: number, day: number, hour = 12, minute = 0): number { @@ -58,3 +59,124 @@ describe('formatDmTimestamp', () => { expect(formatDmTimestamp(lastNight)).toBe('Yesterday'); }); }); + +describe('formatDmPreview', () => { + it('returns null for null lastMessage', () => { + expect(formatDmPreview(null)).toBeNull(); + }); + + it('returns text content when no attachments', () => { + expect(formatDmPreview({ + content: 'hello world', + })).toBe('hello world'); + }); + + it('returns text content when attachments array is empty', () => { + expect(formatDmPreview({ + content: 'hello world', attachments: [], + })).toBe('hello world'); + }); + + it('shows image icon for image-only message', () => { + expect(formatDmPreview({ + content: null, + attachments: [{ type: 'image/png', filename: 'photo.png' }], + })).toBe('📷 Image'); + }); + + it('shows video icon for video-only message', () => { + expect(formatDmPreview({ + content: null, + attachments: [{ type: 'video/mp4', filename: 'clip.mp4' }], + })).toBe('🎬 Video'); + }); + + it('shows audio icon for audio-only message', () => { + expect(formatDmPreview({ + content: null, + attachments: [{ type: 'audio/mpeg', filename: 'song.mp3' }], + })).toBe('🎵 Audio'); + }); + + it('shows file icon with filename for other types', () => { + expect(formatDmPreview({ + content: null, + attachments: [{ type: 'application/pdf', filename: 'report.pdf' }], + })).toBe('📎 report.pdf'); + }); + + it('shows count for multiple attachments without text', () => { + expect(formatDmPreview({ + content: null, + attachments: [ + { type: 'image/png', filename: 'a.png' }, + { type: 'image/jpeg', filename: 'b.jpg' }, + ], + })).toBe('📎 2 files'); + }); + + it('appends image icon to text when text + image', () => { + expect(formatDmPreview({ + content: 'check this out', + attachments: [{ type: 'image/png', filename: 'photo.png' }], + })).toBe('check this out 📷'); + }); + + it('appends video icon to text when text + video', () => { + expect(formatDmPreview({ + content: 'look at this', + attachments: [{ type: 'video/webm', filename: 'vid.webm' }], + })).toBe('look at this 🎬'); + }); + + it('appends audio icon to text when text + audio', () => { + expect(formatDmPreview({ + content: 'listen', + attachments: [{ type: 'audio/ogg', filename: 'voice.ogg' }], + })).toBe('listen 🎵'); + }); + + it('appends file icon to text when text + file', () => { + expect(formatDmPreview({ + content: 'here you go', + attachments: [{ type: 'application/zip', filename: 'archive.zip' }], + })).toBe('here you go 📎'); + }); + + it('uses generic file icon for mixed attachment types with text', () => { + expect(formatDmPreview({ + content: 'stuff', + attachments: [ + { type: 'image/png', filename: 'a.png' }, + { type: 'application/pdf', filename: 'b.pdf' }, + ], + })).toBe('stuff 📎'); + }); + + it('returns null for message with no content and no attachments', () => { + expect(formatDmPreview({ + content: null, + })).toBeNull(); + }); + + it('returns null for empty string content and no attachments', () => { + expect(formatDmPreview({ + content: '', + })).toBeNull(); + }); + + // Compatibility with Attachment type (mimetype/originalName field names) + it('works with mimetype and originalName fields (Attachment shape)', () => { + expect(formatDmPreview({ + content: null, + attachments: [{ mimetype: 'image/png', originalName: 'photo.png' }], + })).toBe('📷 Image'); + }); + + it('appends icon to text with mimetype/originalName fields', () => { + expect(formatDmPreview({ + content: 'check this', + attachments: [{ mimetype: 'video/mp4', originalName: 'clip.mp4' }], + })).toBe('check this 🎬'); + }); +}); diff --git a/packages/web/src/utils/dmFormatters.ts b/packages/web/src/utils/dmFormatters.ts index 0915b9a7..37845198 100644 --- a/packages/web/src/utils/dmFormatters.ts +++ b/packages/web/src/utils/dmFormatters.ts @@ -1,3 +1,92 @@ +// ─── 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 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) + */ +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}`; +} + +// ─── DM Timestamp Formatting ───────────────────────────────────────────────── + /** * Smart timestamp for DM sidebar items. * Today → time ("4:32 PM"), Yesterday → "Yesterday",