Design
llms.txt digitalcrew.tech

Unibox

MessageBubble

One message in a Unibox thread: your side in the brand gradient, theirs in muted glass, each rising in from its own side of the conversation.

MaxUniboxDigitalCrew.MessageBubble
MessageBubble · liveOpen

Guidelines#

Use it for email, LinkedIn and WhatsApp threads. Render bubbles in a flex flex-col container, because they align with self-end and self-start.

What you provide

  • message: a MessageEntity, meaning a messages row with direction, status, subject, text, attachments, sent_at, is_private and metadata.
  • enterIndex: position with the newest message first (default 0).
  • layoutDependency: pass the messages array.
  • For the account owner: canManagePrivacy (default false), onTogglePrivacy(message, isPrivate) and privacyPending.

Anatomy

  • Size: max-w-[min(600px,88%)] px-3.5 py-2.5.
  • Outbound: rounded-[18px_18px_4px_18px], from-primary to-[oklch(0.62_0.19_35)] gradient, text-primary-foreground, shadow-md shadow-primary/25.
  • Inbound: rounded-[18px_18px_18px_4px], bg-muted/80 ring-1 ring-border/50 backdrop-blur-sm.
  • Private messages get an amber ring and an "Only you" lock.
  • The subject is text-sm font-semibold over a hairline. Bodies over 700 characters fold to 500 behind "Show more".
  • Attachments: images inline, audio and video with players, other files as outline chips with their size.
  • Footer: text-xs font-light italic time and, on outbound messages, a delivery-status icon with a tooltip.

Motion: enters from y: 16, x: ±12 (the sender's side), scale 0.96 and 4px blur, on SPRING_ENTER with staggerDelay(enterIndex, 0.03, 0.24). Reduced motion renders it in place.

Don't add names or avatars inside the bubble. Its side already says who spoke.

Facts#

GroupUnibox
ProductMax
Globalwindow.DigitalCrew.MessageBubble
Sourcemax-agent: src/features/unibox/ui/components/message-bubble.tsx
Import in the appimport { MessageBubble } from "@/features/unibox/ui/components/message-bubble";

API#

TypeScript declarations emitted from src/features/unibox/ui/components/message-bubble.tsx.

interface MessageBubbleProps {
    message: MessageEntity;
    /**
     * Position in the thread's render order (newest first), used to stagger the
     * entrance when a conversation opens. New arrivals get index 0.
     */
    enterIndex?: number;
    /**
     * Layout re-measurement gate: pass the messages identity so motion only
     * re-measures bubbles when the thread changes, not on every parent
     * re-render (e.g. each composer keystroke).
     */
    layoutDependency?: unknown;
    /**
     * The viewer connected the account behind this thread, so they may hide
     * individual messages from everyone else. Undefined for everyone else —
     * the control simply isn't rendered.
     */
    canManagePrivacy?: boolean;
    /** Toggle this message's privacy; the parent persists and refreshes. */
    onTogglePrivacy?: (message: MessageEntity, isPrivate: boolean) => void;
    /** True while the toggle is in flight, so the control can't double-fire. */
    privacyPending?: boolean;
}
export declare function MessageBubble({ message: msg, enterIndex, layoutDependency, canManagePrivacy, onTogglePrivacy, privacyPending, }: MessageBubbleProps): 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 MIN = 60 * 1000;
  function ago(m) { return new Date(Date.now() - m * MIN).toISOString(); }
  function msg(o) {
    var t = ago(o.ago);
    return {
      id: o.id, chat_id: "7f3a0000-0000-4000-8000-000000000001", channel: "email", message_id: "prov-" + o.id,
      parent_message_id: null, replied_message_id: null, in_reply_to: null,
      subject: o.subject || null, text: o.text, html_content: null,
      direction: o.dir, status: o.status || null, open_count: o.status === "read" ? 3 : 0, click_count: 0,
      tracking_id: null, mail_type: "single", attachments: o.attachments || [],
      prospect_id: "p-claire", account_id: "5a1c2e10-0000-4000-8000-000000000001", campaign_id: null,
      workflow_node_id: null, workflow_execution_id: null, workflow_execution_state_id: null,
      sent_at: t, delivered_at: o.dir === "outbound" ? t : null, open_at: o.status === "read" ? ago(o.ago - 4) : null,
      clicked_at: null, replied_at: null, bounced_at: null, unsubscribed_at: null, marked_as_spam_at: null,
      error_category: null, error_reason: null, workspace_id: "9d7e0000-0000-4000-8000-000000000001",
      is_private: false, metadata: {}, created_at: t, status_changed_at: t, updated_at: t
    };
  }
  var THREAD = [
    msg({ id: "m1", dir: "outbound", ago: 190, status: "read", subject: "Northwind × Digital Crew: faster month-end close",
      text: "Hi Claire, congrats on the new role. Finance teams your size lose two days a month to manual reconciliation. Worth 20 minutes next week?" }),
    msg({ id: "m2", dir: "inbound", ago: 150,
      text: "Hi Mathieu, timing is good: we're reviewing tooling for Q1. Before a call, our security team needs this questionnaire back.",
      attachments: [{ id: "att-1", kind: "file", name: "Northwind-security-questionnaire.pdf", mime: "application/pdf", size_bytes: 253952, duration: null, voice_note: null, unavailable: null, storage_path: null }] }),
    msg({ id: "m3", dir: "outbound", ago: 64, status: "delivered", text: "Done, returned it with our SOC 2 report. Does Thursday at 10:00 CET work for a first call?" }),
    msg({ id: "m4", dir: "inbound", ago: 12, text: "Thursday at 10 works. Please send the invite to my assistant, Anouk." }),
    msg({ id: "m5", dir: "outbound", ago: 3, status: "sent", text: "Invite sent to you and Anouk. Talk Thursday!" })
  ];
  function App() {
    return h("div", { className: "mx-auto flex max-w-[640px] flex-col gap-2 rounded-2xl border border-border/60 bg-background p-4" },
      THREAD.map(function (m, i) {
        return h(DC.MessageBubble, { key: m.id, message: m, enterIndex: THREAD.length - 1 - i, layoutDependency: THREAD });
      }));
  }
  ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>