# 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.

## Facts

- **Product**: Max
- **Family**: Unibox
- **Global**: `window.DigitalCrew.MessageBubble`
- **Source**: `max-agent: src/features/unibox/ui/components/message-bubble.tsx`
- **Import in the app**: `import { MessageBubble } from "@/features/unibox/ui/components/message-bubble";`
- **Live preview**: /design/components/message-bubble/preview.html
- **Page**: /design/components/message-bubble

## 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.

## API

```ts
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

```html
<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>
```

## More in Unibox

- [ChannelSegments](/design/components/channel-segments.md): The Unibox channel filter: a rail of icons where one shared pill glides to the selected channel, and only that segment spells out its name.
- [ChatListItem](/design/components/chat-list-item.md): One conversation in the Unibox list: a motion row that staggers in, glides when a new reply reorders the inbox, and pops its unread badge.
- [MessageComposer](/design/components/message-composer.md): 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.
- [ReplySuggestions](/design/components/reply-suggestions.md): The band between a Unibox thread and its composer: ask for a few replies, watch them arrive, pick one.
- [SyncProgressPanel](/design/components/sync-progress-panel.md): The dialog behind the Unibox sync button.
- [UniboxIdlePanel](/design/components/unibox-idle-panel.md): The Unibox's empty conversation pane: a breathing glass orb with the three channels in slow orbit and a quiet "Listening for new replies" line.
