Unibox
MessageComposer
The Unibox reply box: text, emoji and staged images in one rounded field, with a send button that springs awake when there is something to send.
Guidelines#
Use it for replying in a conversation on any channel. Emoji are plain UTF-8 in the text.
What you provide
- Text:
valueandonChange. - Images:
attachments(ComposerAttachment[], each{ id, file, previewUrl }),onAddFiles(files)andonRemoveAttachment(id). - Sending:
onSubmit(the parent sends and clears) andisSending. - Optional:
takeoverNotice(default false), which warns that sending stops Max's sequence for this contact; andinputRef.
Anatomy
- Staged images are
size-20 rounded-xlthumbnails with a remove button on hover. - The field is
rounded-2xl border-input bg-background/70 px-2 py-1 backdrop-blur-sm. On focus-within it gainsborder-primary/40 ring-2 ring-primary/20 shadow-lg shadow-primary/10over 200ms. - Round
size-9attach and emoji buttons, then a one-row textarea capped atmax-h-32. - Send is a
size-9circle:bg-primary shadow-md shadow-primary/30when there is something to send, muted otherwise. Belowsmit becomes a labelled "Send" button.
Behaviour
- Enter sends and Shift+Enter adds a line. A pasted file is staged, and an image alone is sendable.
- The file picker accepts PNG, JPEG, GIF and WebP. The app's staging hook limits a message to 5 images and 4 MB in total.
- The send button rests at scale 0.94 while disabled and dips to 0.86 on tap. Reduced motion removes both.
Don't submit for the user, and don't hide the takeover notice while a Max sequence may still run.
Facts#
| Group | Unibox |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.MessageComposer |
| Source | max-agent: src/features/unibox/ui/components/message-composer.tsx |
| Import in the app | import { MessageComposer } from "@/features/unibox/ui/components/message-composer"; |
API#
TypeScript declarations emitted from src/features/unibox/ui/components/message-composer.tsx.
interface MessageComposerProps {
value: string;
onChange: (value: string) => void;
/** Images staged for this message, newest last. */
attachments: ComposerAttachment[];
onAddFiles: (files: Iterable<File> | null | undefined) => void;
onRemoveAttachment: (id: string) => void;
/** Send: the parent owns the request and clears the composer. */
onSubmit: () => void;
isSending: boolean;
/** Explain the campaign consequence before this irreversible human send. */
takeoverNotice?: boolean;
/**
* The text area itself, handed back to the parent. A reply suggestion is
* dropped into the composer by the parent, which then puts the caret here
* so the user can edit it without reaching for the mouse.
*/
inputRef?: RefObject<HTMLTextAreaElement | null>;
}
export declare function MessageComposer({ value, onChange, attachments, onAddFiles, onRemoveAttachment, onSubmit, isSending, takeoverNotice, inputRef, }: MessageComposerProps): JSX.Element;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
var SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff4ec"/><stop offset="1" stop-color="#ffd9c2"/></linearGradient></defs>' +
'<rect width="160" height="160" fill="url(#g)"/>' +
'<rect x="18" y="20" width="84" height="9" rx="4" fill="#3b2a22" opacity=".8"/>' +
'<rect x="18" y="36" width="58" height="6" rx="3" fill="#3b2a22" opacity=".35"/>' +
'<rect x="22" y="104" width="18" height="36" rx="3" fill="#f26b3a" opacity=".55"/>' +
'<rect x="50" y="88" width="18" height="52" rx="3" fill="#f26b3a" opacity=".7"/>' +
'<rect x="78" y="70" width="18" height="70" rx="3" fill="#f26b3a" opacity=".85"/>' +
'<rect x="106" y="54" width="18" height="86" rx="3" fill="#f26b3a"/>' +
'</svg>';
var PREVIEW = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(SVG);
function staged(name, url, size) {
return { id: name, file: new File([new Uint8Array(size)], name, { type: "image/png" }), previewUrl: url };
}
var START_TEXT = "Thursday at 10 works on our side too. Sharing the Q1 rollout plan so Anouk can forward it.";
function App() {
var v = React.useState(START_TEXT), value = v[0], setValue = v[1];
var a = React.useState(function () { return [staged("northwind-q1-rollout.png", PREVIEW, 184320)]; }), atts = a[0], setAtts = a[1];
var s = React.useState(false), sending = s[0], setSending = s[1];
return h("div", { className: "mx-auto max-w-[640px]" },
h(DC.MessageComposer, {
value: value, onChange: setValue, attachments: atts, isSending: sending, takeoverNotice: true,
onAddFiles: function (files) {
var list = Array.prototype.slice.call(files || []);
setAtts(function (prev) {
return prev.concat(list.map(function (f, i) { return { id: f.name + Date.now() + i, file: f, previewUrl: URL.createObjectURL(f) }; }));
});
},
onRemoveAttachment: function (id) { setAtts(function (prev) { return prev.filter(function (x) { return x.id !== id; }); }); },
onSubmit: function () {
setSending(true);
setTimeout(function () { setSending(false); setValue(""); setAtts([]); }, 900);
setTimeout(function () { setValue(START_TEXT); setAtts([staged("northwind-q1-rollout.png", PREVIEW, 184320)]); }, 3200);
}
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>