Design
llms.txt digitalcrew.tech

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.

MaxUniboxDigitalCrew.MessageComposer
MessageComposer · liveOpen

Guidelines#

Use it for replying in a conversation on any channel. Emoji are plain UTF-8 in the text.

What you provide

  • Text: value and onChange.
  • Images: attachments (ComposerAttachment[], each { id, file, previewUrl }), onAddFiles(files) and onRemoveAttachment(id).
  • Sending: onSubmit (the parent sends and clears) and isSending.
  • Optional: takeoverNotice (default false), which warns that sending stops Max's sequence for this contact; and inputRef.

Anatomy

  • Staged images are size-20 rounded-xl thumbnails 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 gains border-primary/40 ring-2 ring-primary/20 shadow-lg shadow-primary/10 over 200ms.
  • Round size-9 attach and emoji buttons, then a one-row textarea capped at max-h-32.
  • Send is a size-9 circle: bg-primary shadow-md shadow-primary/30 when there is something to send, muted otherwise. Below sm it 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#

GroupUnibox
ProductMax
Globalwindow.DigitalCrew.MessageComposer
Sourcemax-agent: src/features/unibox/ui/components/message-composer.tsx
Import in the appimport { 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>