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.
This commit is contained in:
@@ -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 🎬');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user