The media sharing system supports image and audio attachments with automatic preview generation, caption support, and file metadata tracking.
All media attachments use a unified interface:
export interface MediaAttachment {
id: string
type: "image" | "audio"
url: string
thumbnailUrl?: string
width?: number
height?: number
sizeInBytes?: number
caption?: string
waveform?: number[]
localObjectUrl?: string
}
Field descriptions
id - Unique identifier for the attachment
type - Media type (image or audio)
url - Full resolution media URL
thumbnailUrl - Optional lower resolution preview URL
width, height - Dimensions for image attachments
sizeInBytes - File size for storage tracking
caption - Optional text caption
waveform - Audio visualization data (for audio type)
localObjectUrl - Temporary blob URL for local uploads
The localObjectUrl is used for preview before upload and should be revoked after use to prevent memory leaks.
Send media attachments through the message composer:
const sendMessage = useChatStore((state) => state.sendComposerPayload)
sendMessage(chatId, {
media: {
id: "media-123",
type: "image",
url: "https://example.com/photo.jpg",
width: 1920,
height: 1080,
caption: "Sunset at the beach"
}
})
File upload handling
The MessageComposer component handles file selection and preview:
Dropzone integration
components/chat/message-composer.tsx
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const media = acceptedFiles.map<MediaAttachment>((file) => {
const objectUrl = URL.createObjectURL(file)
return {
id: `upload-${file.name}-${file.lastModified}`,
type: "image",
url: objectUrl,
localObjectUrl: objectUrl,
sizeInBytes: file.size,
caption: file.name,
}
})
setAttachments((prev) => [...prev, ...media])
},
[]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
accept: {
"image/*": [],
},
})
User selects files
Files are selected via drag-and-drop or file picker
Create object URLs
Temporary blob URLs are created for local preview
Display previews
Thumbnails are shown in the composer
Send message
Files are uploaded and message is sent
Clean up
Object URLs are revoked to free memory
Attachment preview
components/chat/message-composer.tsx
{attachments.length > 0 ? (
<div className="mb-3 flex gap-2 overflow-x-auto pb-1">
{attachments.map((attachment) => (
<figure
key={attachment.id}
className="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl border border-border/60"
>
<img
src={attachment.url}
alt={attachment.caption ?? "Attachment"}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={(event) => {
event.preventDefault()
setAttachments((prev) =>
prev.filter((item) => {
if (item.id === attachment.id && item.localObjectUrl) {
URL.revokeObjectURL(item.localObjectUrl)
}
return item.id !== attachment.id
})
)
}}
className="absolute right-1 top-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-medium text-white"
>
✕
</button>
</figure>
))}
</div>
) : null}
Always revoke object URLs when removing attachments or unmounting components to prevent memory leaks.
Memory management
Proper cleanup of object URLs is essential:
components/chat/message-composer.tsx
useEffect(() => {
return () => {
attachments.forEach((attachment) => {
if (attachment.localObjectUrl) {
URL.revokeObjectURL(attachment.localObjectUrl)
}
})
}
}, [attachments])
The MessageBubble component renders media attachments:
components/chat/message-bubble.tsx
{message.contentType === "image" && message.media ? (
<button
type="button"
onClick={() => onMediaPreview?.(message.media as MediaAttachment)}
className="group mb-2 block overflow-hidden rounded-2xl border border-border/40 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
<Image
src={message.media.url}
alt={message.media.caption ?? "Shared media"}
width={message.media.width ?? 640}
height={message.media.height ?? 360}
className="h-auto max-h-[320px] w-full object-cover transition duration-500 group-hover:scale-[1.02]"
/>
{message.media.caption ? (
<p className="px-3 pb-2 pt-2 text-sm text-foreground/90">
{message.media.caption}
</p>
) : null}
</button>
) : null}
Image optimization
The component uses Next.js Image for automatic optimization:
- Responsive sizing with
max-h-[320px]
- Lazy loading by default
- Automatic format selection (WebP when supported)
- Hover effect with subtle zoom
Images are constrained to a maximum height of 320px to maintain consistent message bubble sizing.
Media message content type
Messages with media use the appropriate content type:
export type MessageContentType = "text" | "image" | "audio"
export interface Message {
id: string
chatId: string
authorId: string
contentType: MessageContentType
text?: string
media?: MediaAttachment
// ...
}
Helper function for creating inbound media messages:
export function createInboundMediaMessage(options: {
chatId: string
authorId: string
media: MediaAttachment
createdAt?: Date
}): Message {
const createdAt = options.createdAt ?? new Date()
const iso = createdAt.toISOString()
return {
id: generateMessageId(),
chatId: options.chatId,
authorId: options.authorId,
contentType: options.media.type,
media: options.media,
status: "read",
createdAt: iso,
updatedAt: iso,
}
}
Message preview text
Generate appropriate preview text for media messages:
export function derivePreview(message: Message) {
if (message.contentType === "image") {
return message.media?.caption ?? "📷 Photo"
}
if (message.contentType === "audio") {
return "🎧 Audio message"
}
return message.text ?? "New message"
}
This preview appears in the chat list:
components/chat/sidebar.tsx
<p className="truncate text-xs text-muted-foreground">
{isTyping ? "Typing…" : chat.lastMessagePreview ?? "No messages yet"}
</p>
Sending attachments
When the user sends a message with attachments:
components/chat/message-composer.tsx
const handleSend = useCallback(() => {
if (!canSend) return
attachments.forEach((attachment) => {
onSend(chatId, { media: attachment })
if (attachment.localObjectUrl) {
URL.revokeObjectURL(attachment.localObjectUrl)
}
})
const trimmed = text.trim()
if (trimmed.length > 0) {
onSend(chatId, { text: trimmed })
}
setText("")
setAttachments([])
onClearDraft(chatId)
}, [attachments, canSend, chatId, onClearDraft, onSend, text])
Each attachment is sent as a separate message, followed by any text content.
For browsers without drag-and-drop support:
components/chat/message-composer.tsx
<Button
size="icon"
variant="ghost"
className="h-10 w-10 text-muted-foreground"
type="button"
onClick={(event) => {
event.preventDefault()
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.multiple = true
input.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files) {
onDrop(Array.from(target.files))
}
}
input.click()
}}
>
<Paperclip className="h-5 w-5" />
<span className="sr-only">Attach media</span>
</Button>
Example of seeding media messages:
const message: Message = {
id: messageId,
chatId,
authorId,
contentType: isMedia ? "image" : "text",
text: isMedia ? undefined : sampleText(authorId === profile.id ? "outgoing" : "incoming", i),
media: isMedia
? {
id: nextId("media"),
type: "image",
url: mediaPool[i % mediaPool.length],
thumbnailUrl: mediaPool[i % mediaPool.length] + "&w=320",
width: 1280,
height: 720,
caption:
authorId === profile.id
? captions[(i / (opts.includeMediaEvery ?? 1)) % captions.length]
: undefined,
}
: undefined,
// ...
}