Overview
The MessageComposer component provides the message input interface with emoji picker, file attachments via drag-and-drop or click, and automatic draft persistence. It manages the composition state and triggers send callbacks.
Usage
import { MessageComposer } from "@/components/chat/message-composer"
function ChatInterface() {
return (
<MessageComposer
chatId={activeChat.id}
draftText={savedDraft?.text}
draftAttachments={savedDraft?.attachments}
onChangeDraft={handleDraftUpdate}
onClearDraft={handleDraftClear}
onSend={handleSendMessage}
/>
)
}
Props
The ID of the chat conversation this composer is for
Initial draft text to populate the input field
Initial draft attachments to display as previews
onChangeDraft
(chatId: string, draft: { text?: string; attachments?: MediaAttachment[] }) => void
required
Callback when draft content changes (debounced for persistence)
onClearDraft
(chatId: string) => void
required
Callback to clear the draft after successfully sending
onSend
(chatId: string, payload: { text?: string; media?: MediaAttachment }) => void
required
Callback when a message is sent (called once per attachment + once for text if present)
Type definitions
interface MessageComposerProps {
chatId: string
draftText?: string
draftAttachments?: MediaAttachment[]
onChangeDraft: (chatId: string, draft: { text?: string; attachments?: MediaAttachment[] }) => void
onClearDraft: (chatId: string) => void
onSend: (chatId: string, payload: { text?: string; media?: MediaAttachment }) => void
}
interface MediaAttachment {
id: string
type: "image" | "audio"
url: string
thumbnailUrl?: string
width?: number
height?: number
sizeInBytes?: number
caption?: string
waveform?: number[]
localObjectUrl?: string
}
Draft persistence
The component automatically saves drafts as the user types:
const [text, setText] = useState(draftText)
const [attachments, setAttachments] = useState<MediaAttachment[]>(draftAttachments)
useEffect(() => {
onChangeDraft(chatId, { text, attachments })
}, [chatId, text, attachments, onChangeDraft])
Drafts are persisted in real-time, so users never lose their work when switching conversations.
Sending messages
The send handler processes text and attachments:
const handleSend = useCallback(() => {
if (!canSend) return
// Send each attachment as a separate message
attachments.forEach((attachment) => {
onSend(chatId, { media: attachment })
if (attachment.localObjectUrl) {
URL.revokeObjectURL(attachment.localObjectUrl)
}
})
// Send text message if present
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. Text is sent only if non-empty after trimming.
Keyboard shortcuts
- Enter: Send message
- Shift + Enter: Add new line
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
handleSend()
}
},
[handleSend]
)
Emoji picker
Emoji picker is powered by @emoji-mart/react:
const EmojiPicker = dynamic<PickerProps>(() => import("@emoji-mart/react"), {
ssr: false,
})
<Popover open={isEmojiOpen} onOpenChange={setIsEmojiOpen}>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost" className="h-10 w-10 text-muted-foreground">
<Smiley className="h-5 w-5" />
<span className="sr-only">Open emoji picker</span>
</Button>
</PopoverTrigger>
<PopoverContent className="border-none bg-transparent p-0 shadow-2xl" align="start">
<EmojiPicker
data={data}
onEmojiSelect={(emoji: { native?: string }) => {
if (emoji?.native) {
setText((prev) => `${prev}${emoji.native}`)
}
}}
theme="light"
previewPosition="none"
/>
</PopoverContent>
</Popover>
The emoji picker is dynamically imported with ssr: false to reduce initial bundle size.
File attachments
The component supports two methods for adding attachments:
Drag and drop
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
accept: {
"image/*": [],
},
})
<div
{...getRootProps()}
className={cn(
"relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3 transition-colors",
isDragActive && "border-primary bg-primary/5"
)}
>
<input {...getInputProps()} />
{/* Composer content */}
</div>
Click to upload
<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>
Attachment preview
Selected attachments display as thumbnails with remove buttons:
{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}
Object URL cleanup
Local file URLs are properly cleaned up to prevent memory leaks:
// Cleanup on unmount
useEffect(() => {
return () => {
attachments.forEach((attachment) => {
if (attachment.localObjectUrl) {
URL.revokeObjectURL(attachment.localObjectUrl)
}
})
}
}, [attachments])
// Cleanup on send
attachments.forEach((attachment) => {
onSend(chatId, { media: attachment })
if (attachment.localObjectUrl) {
URL.revokeObjectURL(attachment.localObjectUrl) // Clean up after sending
}
})
Object URLs created via URL.createObjectURL() must be manually revoked to prevent memory leaks.
Text input
The textarea auto-expands with content:
<Textarea
placeholder="Type a message"
value={text}
rows={1}
onChange={(event) => setText(event.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[48px] flex-1 resize-none rounded-2xl bg-background/90 px-4 py-3 text-sm shadow-inner"
/>
The send button is disabled when there’s no content:
const canSend = useMemo(
() => text.trim().length > 0 || attachments.length > 0,
[text, attachments]
)
<Button
size="icon"
disabled={!canSend}
onClick={(event) => {
event.preventDefault()
handleSend()
}}
className="h-11 w-11 rounded-full bg-primary text-primary-foreground shadow-lg transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted"
>
<PaperPlaneTilt className="h-5 w-5" weight="fill" />
<span className="sr-only">Send message</span>
</Button>
Drag state visual feedback
When files are dragged over the composer, visual feedback is provided:
className={cn(
"relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3 transition-colors",
isDragActive && "border-primary bg-primary/5"
)}
Layout structure
<div {...getRootProps()} className="relative border-t border-border/70 bg-background/95 px-4 pb-4 pt-3">
<input {...getInputProps()} />
{/* Attachment previews */}
{attachments.length > 0 && <AttachmentPreviews />}
{/* Input controls */}
<div className="flex items-end gap-3">
<EmojiPickerButton />
<AttachButton />
<Textarea />
<SendButton />
</div>
</div>